Skip to content
Quilium Docs
Back to site
Reference

Custom fields

Every field type you can declare on a content-type, page-type, collection-type or global field, with its config keys, write format and template output.

Fields are declared inside a type's tabsets.<key>.fields map. Each field has a type and an optional config object. This page catalogues every documented type: the standard types, the special types that drive the form pipeline and remote data, the validators used inside validate, and the image, date and wysiwyg config notes.

The catalogue covers the 20 documented standard types plus 6 special types (26 documented entries). For where each type may be declared, see content-types, itemset-types and the content model.

Placeholder: the skill header claims "26 standard + 6 special", but only 20 distinct standard types are documented. To reconcile the exact 26: grep the engine field-type registry in quilium-frontend / quilium-cms source.

Common options

These keys are valid on every field, regardless of type.

Key Default Notes
label — Required. Backoffice field label.
info — Helper text under the field.
required false Marks the field as mandatory in the editor.
translatable true Set false for slugs, anchors, CSS classes, hex values. See the multilingual model.
default — Initial value. Config/structural fields only (see per-type notes).
placeholder — Placeholder text in the input.
fieldset — Assigns the field to a visual fieldset group declared on the type.
Write format: two shapes, not one. Media fields — image, file — take an array of objects, [{id:'<uuid>'}], because the stored value really is an array of media objects: the server hydrates name, alt, url and versions back onto it when you read it. Every reference by id — items, smartselect with source: items, page — takes a flat array of id strings, ['<uuid>']. Either way, omitting one of these fields on an update can clear it, so resend the ids.

Sending the object shape to an id reference is not rejected today: the server normalises it to flat ids on write and on read, on all three, so rows a previous client stored that way repair themselves. Write the flat shape anyway — the normalisation is a safety net for legacy data, not the contract, and every other layer (the backoffice field, the where filters, the index) is built on flat ids. It matters because of what the object shape used to do before that net existed: on items it corrupted silently, the listing showing the right label while the edit form's select came back empty and where never matched; on page a PUT resolved the objects to nothing and emptied the field on a 200. If you inherit a site where a relation holds data but the backoffice select is empty, rewrite that field once in the flat shape.

Standard types

Twenty documented standard types. Fields not exercised on the live fil-rouge site are flagged below.

Type Key config keys Notes
text translatable, placeholder, info Titles, anchors, CSS classes, URLs, FA icon names. Template output is auto-escaped.
textarea — Meta-description, excerpt, multi-line plain text. Auto-escaped, never piped through safe.
wysiwyg config.limiter (int char cap); required, info, translatable The only documented per-field keys are label / required / info / translatable / config.limiter. Toolbar, allowed tags, formats and link target are not YAML-configurable. The styles palette is site-wide (miscellaneous.wysiwyg.styles). Renders as HTML.
json — Structured editable object. Use info: to document the shape; invalid JSON breaks render. Skill-sourced, not live-confirmed.
embed — Raw HTML / iframe / <script>. Renders unescaped. XSS vector. Skill-sourced, not live-confirmed.
number — Filterable and sortable numeric. Not for phone numbers (leading-zero loss).
slug config.linkedField URL segment, unique, one per type. Per-locale slugs when translatable.
checkbox config.checkedValue, config.uncheckedValue Persists strings instead of a boolean. Allowed as a collection filter.
select config.options (key:Label map), config.multiple, config.mustMatch, config.inlineEditable, config.bulkEditable, default (an existing key) Static enum, keep to 8 or fewer. inlineEditable: true makes the value editable directly in the listing cells; bulkEditable opts into bulk edit.
smartselect config.source = static | items | api (per-mode keys below) Dropdown sourced from a static map, a collection, or a remote API. In source: items mode it is an id reference — write a flat ['&lt;uuid&gt;']. Skill-sourced, not live-confirmed.
items config.itemSet (req), config.fieldLabel, config.multiple, config.autocomplete, config.sort, config.where, config.firstValueIsEmpty Pick item(s) from a collection. Write a flat ['&lt;uuid&gt;'] — an id reference, not a media field.
color config.options (keys = valid CSS color values, values = labels) Brand palette picker.
page config.multiple, config.pageTypes, config.navigations Internal page link. Write a flat ['&lt;uuid&gt;']. Omitting the field on update can clear it, resend ids.
image config.versions.<key>.{label, width, height, quality, resolutions}, config.multiple, config.max, config.onDelete (keep/cascade/orphan) At least one versions.<key> is required. Write [{id:'&lt;media-uuid&gt;'}]. See image versions. onDelete: media fate when the item is trashed — see ItemSet types → Retention.
file config.multiple, config.max, config.onDelete (keep/cascade/orphan) PDF, CV, audio. Write [{id:'&lt;media-uuid&gt;'}], like image. onDelete: media fate when the item is trashed — see ItemSet types → Retention. Skill-sourced, not live-confirmed.
repeat config.limit, config.fields (sub-fields; first must be a stable text/wysiwyg label) Two to ten identical structured items inline.
choice config.choices.<key>.{name, fields} Flow of heterogeneous typed blocks. Discriminator _choice (see note). Each variant has its own fields and template.
date default: now Single day YYYY-MM-DD. Render with | date: 'DD MMMM YYYY'.
calendar RRULE value object: start, end, freq (DAILY/WEEKLY/MONTHLY/YEARLY/''), interval, byday[], count Recurring events. translatable defaults to false. See date and calendar. Skill-sourced, not live-confirmed.
geo config.multiple Latitude/longitude. Template reads .lat / .lng. Skill-sourced, not live-confirmed.

smartselect per-mode config keys:

config.source Keys
static options, multiple, mustMatch, inlineEditable, bulkEditable
items itemSet, fieldLabel, autocomplete, sort, where
api format, src, fieldLabel, fieldValue, searchParamName, auth, headers
_choice vs _type discriminator. Use _choice to discriminate a choice variant in templates. One skill's example shows _type, which is treated as a mistake, the dedicated pattern skill is authoritative and flags _type as wrong. Not exercised live on the fil-rouge site.

Special fields

Form-pipeline and data-fetch types. Declared like any other field, usually one per tabset stage on a form-handling content-type.

Type Key config keys Notes
query itemSet (req), limit (literal int 1..100), page:'{{query.page}}', defaults, sort (list of single-key maps), with, dependsOn, where Render-time collection fetch. {{query.limit}} does not substitute, only {{query.page}} does. Pagination capped at 100, enumerate ?page=N. Heavily used on the live site. See template variables.
validate config.rules.<field>: [rule…], config.redirect (array of one page-id string ["&lt;uuid&gt;"]) Form validation. Errors at content.form.<field>.errors (object {rule:msg}). Posted values at request.body.<field>. Success is a 302 PRG redirect to the target. to/subject/replyTo do not live here.
sendmail config.template, config.attachments, config.smtp{host, port, secure, username, password, from}, config.unflatten to / subject / replyTo are per-instance content-block fields, not config — the schema refuses them outright ("A Sendmail field can't have a 'to' property."); set via the backoffice or update-content custom.<field>.{to,subject,replyTo}, interpolating {{name}} tight-braces. From is settable only through config.smtp.from. Email template reads posted fields at root {{name}}, block fields via _content.custom.<f> — form.<f> renders empty. Schema confirmed live; delivery not exercised (no mailbox on the demo sites).
save config.key (target collection, req), config.attachments, config.path, config.updateField (upsert) Persist to a collection. default: now is not honored, use the system created_at. Live example: saveToCollection: { type: save, config: { key: abonnes } }.
http config.src, config.method (POST/PUT/PATCH), config.timeout (≤60) Forward a submission to a webhook. Response is not exposed to templates. Skill-sourced, not live-confirmed.
api config.format:'json', config.src, config.caching{enabled, validity} (validity in minutes, default 20), config.timeout (≤60), config.defaults, config.headers, config.auth{user, password} Render-time remote JSON fetch, exposed as the parsed object. src resolves in the same variable context as a query's where — including a sibling query field's result, reached by its bare field name ({{itinerary.coords.lat}}; the indexed and field.-prefixed forms both fail silently). dependsOn is refused on an api field. A failed fetch — or a mistyped variable — yields an empty object with the page still at 200, so guard on the branch you loop. A dead endpoint costs the full timeout on every cache miss. See Consume an external feed.
Live-confirmed vs skill-sourced. query, validate, save and api are exercised on live demo sites. sendmail's config keys were validated against a live schema, but no message was ever sent — the demo sites have no mailbox. http remains skill-sourced.

Where-clause operators

The vocabulary a query accepts under where:. It is shared by items and api config too, anywhere a where appears.

Operator Matches
and: / or: logical grouping — a list of sub-clauses, one clause per item
= < > <= >= comparison, numeric or date
like: / ilike: wildcard on one named field, % as the placeholder; ilike is case-insensitive
in: value present in an array
intree: descendant of a node, for pages held in a navigation tree
?& / ?| multi-value: ALL of / ANY of
search: full text across the fields declared searchable: true on the type
& prefix strict mode on any of the above — see below

Empty variables are dropped, unless the clause is strict

By default a clause whose variable resolves to empty is removed from the query, and the remaining clauses run without it. A where reduced to nothing returns the entire collection. That is deliberate and often useful — an absent ?q= on a search page lists everything rather than nothing — but it is also the single most expensive default here, because the failure is silent and looks like a template bug.

Prefix the operator with & to keep the clause even when its variable is empty, so a missing value yields zero rows instead of all of them:

where:
  '&=':
    slug: '{{route.article.slug}}'
    active: active

Use strict mode for anything a detail page depends on (slug), and for filters that must always apply (active, a published flag). Use the default mode when an absent value legitimately means "no filter".

A mistyped variable is indistinguishable from an empty one. Both get the clause dropped, both return more rows than intended, and neither raises. A listing rendering the whole collection where it should render six rows is the signature of a typo in a {{…}} token, not of a broken loop. The known trap is {{current.<field>}}, which does not exist — current exposes only page and locale, and a block's own fields live under {{field.<name>}}.

searchable: true

A per-field boolean on the type. It has one effect: it puts the field within reach of the search: operator. It is not required for like/ilike, which name their field directly, and it does not affect the back-office filters: list.

search is word-based: case- and accent-insensitive, several terms combined with AND, and no partial-word matching — a query on forê does not find forêt. It also reads the translated values of the locale being rendered, so on a bilingual site each language searches its own text. For substring behaviour on one named column, use ilike with % wildcards instead. All measured on the Lisière demo; see Build a blog, section 8.

Validators

Rules listed inside validate.config.rules.<field>: [rule…]. A rule is either a bare keyword or a {rule: {param}} map.

Rule Param Behaviour
required — Value must be non-empty.
email — Must be a valid email format.
recaptcha — Checks g-recaptcha-response.
equals value: '{{field.code}}' Exact match against another field.
file-extensions allowed: 'jpg,png' Comma-separated allow-list.
file-size max: 2097152 Maximum size in bytes.
validation:
  type: validate
  config:
    rules:
      email: [email, required]
      firstName: []          # no rule, but declared — see below
      consent: [required]
rules is the allow-list of persisted fields, not just a validator. A field the form posts but rules does not name is dropped on save, with no error and no trace: the submission still redirects, the collection entry simply comes back without it. Declaring it with an empty array — firstName: [] — persists it without constraining it. Check every name="…" in the form template against this map; the mismatch is silent in both directions.

Spam filtering (antispam)

Beyond the recaptcha rule, a validate field can run the submission through the platform's spam filter — an IP / email / content reputation check. It is opt-in per form, and as with the captcha there is no key to obtain and no account to open: the platform holds the credentials.

validation:
  type: validate
  config:
    rules:
      email: [email, required]
      g-recaptcha-response: [recaptcha]
    antispam:
      email: '{{body.email}}'
      message: 'Contact {{body.name}}: {{body.message}}'

email and message are interpolated from the POST ({{body.<field>}}) and they are all the filter sees: a field the template does not name is not checked. Keep the template in sync with the form — a message referencing fields that no longer exist interpolates to an empty string, and a filter that receives nothing waves everything through.

silent defaults to true: a submission flagged as spam still validates and redirects — the visitor sees a normal success — but every action behind the gate (sendmail, save, http) is skipped. Set silent: false to reject the submission openly, like any failed rule.

When the mail actually leaves

sendmail applies its own gate on top of validation, and it fails silently — the visitor lands on the thank-you page either way:

  • Platform sender (no custom smtp): the mail leaves only if a recaptcha rule validated on this submission. No captcha rule, no notification.
  • Valid custom smtp (host, username, password, from all set): the captcha requirement is lifted.
  • The spam filter said spam: nothing is sent, saved or forwarded, whatever the SMTP situation.
  • Interpolated recipient (to containing {{…}}): allowed only when the spam filter actually checked the submission. A dynamic recipient with no spam check is skipped — that combination is what turns a contact form into an open relay.

save and http only apply the spam gate: they run with or without a captcha.

Engine-sourced: read from the rendering engine's send path, not exercised live.

Image, date and wysiwyg config

Image versions

An image field defines one or more named crops under config.versions.<key>. At least one is required. Templates read field[0].versions.<key>.url.

Version key Behaviour
width + height Crop to that exact box.
width only / height only Proportional resize on that axis.
resolutions: {key: ratio} Generates a srcset from the named ratios.
image:
  type: image
  label: Image
  config:
    multiple: true   # gallery
    max: 12
    versions:
      card:
        label: Card
        width: 800
        height: 600
      cover:
        label: Cover
        width: 1600
        height: 900

multiple: true + max: N turns the field into a gallery (live-confirmed). Write [{id:'&lt;media-uuid&gt;'}].

Date and calendar

  • type: date is a single day, YYYY-MM-DD, with default: now. Render with value | date: 'DD MMMM YYYY'.
  • type: calendar holds an RRULE value object (start, end, freq, interval, byday[], count) for recurring events. The template reads _occurrence.start / _occurrence.end, the single materialised occurrence. translatable defaults to false. Skill-sourced, not live-confirmed.

Wysiwyg

A wysiwyg field is configured minimally per-field: only config.limiter (an integer character cap) beyond the common options. All visual styling comes from the site-wide miscellaneous.wysiwyg.styles palette, not per-field config. The field renders as HTML.

Related


Last updated September 6, 2026.