Documentation
# ReferenceEvery field type, every block and every operator the spec accepts — generated from the schema itself, so this page cannot describe a version that does not exist.
## A site is a directorymy-site/
site.yaml name, theme, header and footer, integrations
content/*.yaml one content type per file
pages/*.yaml one page per file
logic/*.yaml one automation per file
data/*.yaml rows, if you keep any in version control npx @forinda/fcms-cli init my-site writes that, already valid and
already canonical. The file a thing lives in is derived from its key,
so fcms fmt may move a node into the file it belongs in — that is
the bargain, and it is what keeps round-tripping exact.
key: property
label: Property
labelPlural: Properties
titleField: name
permalink: /stay/{{ entry.slug }}
fields:
- { name: name, label: Name, type: text, required: true }
- { name: city, label: City, type: reference, to: city, filterable: true }
- { name: stars, label: Star rating, type: number, min: 1, max: 5, filterable: true }
- name: score
label: Guest score
type: aggregate
of: review
on: property
field: score
fn: avg Every field takes name, label, and optionally
required, unique, filterable,
help. filterable is what makes a field
searchable — it compiles to a partial index scoped to your site and
type. unique is enforced by an index of its own.
Field types
| Type | Also takes | |
|---|---|---|
text | One line of plain text. | max |
richtext | Formatted prose, edited in the admin. | — |
number | A number, sortable and filterable. | min, max |
boolean | Yes or no. An unticked box means no, not missing. | — |
date | A day, with no time on it. | — |
datetime | A moment, normalised to UTC on write. | — |
email | An address, checked for shape. | — |
phone | A number people ring. | — |
url | A link somewhere else. | — |
asset | An uploaded file. | accept: image | video | document | any |
geo | One coordinate. Pasted from a map — nothing here geocodes. | — |
select | One of a closed list. | options: [{ value, label }] |
reference | A relation to another content type. Integrity is declared, not implied. | to, many |
state | Where a row is in a process, and which moves are allowed. | values, initial, transitions |
hours | Opening hours as a week, not a document. | — |
Worked out by the platform
Never written by a form, an API caller or an agent — accepting one would let a caller claim their own rating, or their own price.
| Type | Also takes | |
|---|---|---|
aggregate | A number counted or averaged over rows of another type that reference this one. | of, on, field, fn: count | avg | sum | min | max |
computed | A number this type works out for itself, from a declared formula. | formula, precision |
key: search
path: /search
title: Search results
blocks:
- type: list
data:
from: property
where:
- { field: stars, op: gte, value: { param: stars } }
sort: { param: sort, allow: [score, stars], default: score, dir: desc }
limit: 25
item:
- type: card
attrs: { heading: "{{ item.name }}", to: "/stay/{{ item.slug }}" } A page with collection: { from: property } answers for
one row at a time, and its lists can name that row with
{ entry: slug } — "the rooms of this property".
Blocks
| Block | attrs | style.variant | |
|---|---|---|---|
account | Sign in, register, or sign out — for the site's own visitors. | mode, submit | — |
button | A link styled as a button. | label, to | primary, secondary, outline |
card | A titled block of content, usually one row of a list. | heading, body, meta, image, to | plain, elevated, price |
disclosure | A label that opens to reveal what is inside it. | label, open | — |
divider | A horizontal rule. | — | — |
facets | Filter options for one field, with how many rows each would match. | for, field, param, title, order | — |
field | One input inside a form. | name, label, type, required, placeholder | — |
filters | A search form for the parameters this page's list actually reads. | for, submit | — |
footer | Page footer. | text | — |
form | A form over a content type. Submits to the site when the type allows it. | for, submitLabel | — |
gallery | Several pictures in a grid. | images, alt | — |
grid | Children in a responsive grid. | — | — |
heading | A headline. | text, level | — |
image | A picture. | src, alt, width, height | — |
list | Renders one child template per row of a query. | — | — |
map | Where this is, on a map. | at, zoom, title | — |
nav | Site navigation. | links, label | — |
pager | Previous and next links for a paged list. | — | — |
results-count | How many rows the current filters matched. | one, many | — |
richtext | Formatted text authored by the site owner. | html | — |
row | Children in a horizontal row that wraps. | — | — |
save-button | Lets a signed-in visitor keep this entry. | for, label, saved | — |
section | A full-width band of the page. | — | — |
spacer | Vertical space. | — | — |
stack | Children in a vertical column. | — | — |
text | A paragraph of plain text. | text | — |
disclosure is how a spec expresses a toggle — a mobile menu, an
FAQ answer, a filter rail that starts collapsed. It renders
<details>, so it is keyboard-operable, announces its own open
state, and closes on Escape without a line of JavaScript. A header that wants a
menu on a phone and a row of links on a desktop is two blocks gated by
hideOn, with the links themselves in a components:
entry so the list is written once.
Styling
Three tiers, and the order is the point: a theme first, then structured properties, then custom CSS as the pressure valve.
| 1. Theme | Colours, fonts, a type scale, radius. One decision for the whole site. |
2. style | padding, gap, width, contentWidth, align, justify, background, textColor, radius, border, shadow, textAlign, fontSize, fontWeight, cols, hideOn, variant. Responsive where it makes sense. |
3. css | Declarations for the block, and one level of nested selectors for what it renders inside itself. |
contentWidth measures a block's children and leaves the
block alone — a full-bleed coloured band with contained content, which is the
commonest layout on any site and which width cannot express, because
constraining a section constrains its background too.
type: section
style: { background: brand, contentWidth: container }
type: card
style: { variant: price }
css: |
img { aspect-ratio: 1 }
h3 { font-size: 1.1rem }
&:hover { opacity: .95 } A block author never writes a selector. Declarations apply to
the block; a nested rule is prefixed with the block's own class, and &
means the block itself. One level deep, and anything that is not a declaration is
dropped — so custom CSS cannot reach an element the block does not own, whatever
is typed into it. Site-wide CSS is the one explicit escape hatch, and it is gated
to the developer role.
data is the only iteration in the spec. There is no
for: data plus item is the whole story.
limitis required and capped at 100. An unbounded query is a performance problem you cannot see until your business is doing well.- An absent parameter drops its condition, because a search
page has to work before anything is typed. An unresolvable
{ entry: … }returns nothing instead — "this property's rooms" with no property is zero rooms, never every room on the site. - Facets count with their own filter lifted, so ticking "4 stars" does not make every other rating read zero.
Conditions
A condition is a triple, never a string. { field, op, value }
is data — it diffs, it validates, and a model can propose one safely.
item.price > 100 is code hiding in a value, and it is not
accepted anywhere.
Operators: eq, ne, lt, lte, gt, gte, in, contains, exists, empty.
A value is a literal, a { param: name }
from the request, or a { entry: field } from the row a
detail page is for.
Formulas
Arithmetic is a declared tree, not an expression string, so you can read one
and say what it depends on without running it. Six operations —
add, subtract, multiply,
divide, min, max, round —
over a field, a request parameter, a literal, or one hop across a
declared reference.
- name: deposit
label: Deposit
type: computed
precision: 2
formula:
op: multiply
of:
- { ref: room, field: price }
- { field: nights } That hop is what lets a price come from the room rather than from the form —
and a computed field is refused as input, so the amount a payment
reads cannot be posted by whoever is paying it.
filterable compiles to, why
dates are indexed as text, and where to look when a query is slow.