Bindings
Bindings
Any string prop can hold a placeholder, which is barakoCMS's workflow template syntax with an optional format and fallback:
{ "type": "text", "props": { "value": "Welcome to {{site.Name}}", "variant": "display" } }
{ "type": "text", "props": { "value": "{{item.Fee | money ?? Free}}" } }
{ "type": "text", "props": { "value": "{{page.PublishedAt | date}}" } }
Scopes are site (the tenant's settings and resolved identity), page (the entry the page
renders), item (the row inside a source or a repeat) and query (URL parameters). An item
reads its entry's own fields by name, with the collection's roles (Title, Summary, Body,
Date and the rest) laid over them only where the collection's fields maps that role, so an
unmapped Body is still the entry's own Body. count, sum and group are about a set of rows
rather than one, and are described with source below. viewer arrives with #7. Formats are text, date, datetime, time, money, number, upper and
lower; money uses the tenant's Currency setting, or a plain amount when it has none.
Paths, formats and fallbacks only. There are no expressions and no JavaScript. Everything resolves
on the server as the request's tenant, before a component is called, so a binding never makes the
browser call the API. A placeholder that finds no value renders its fallback and is reported in the
server log, never a crash. What a binding resolves to is checked against the field again, so a link
whose stored field holds javascript: drops the block; and a resolved value is never rescanned, so
one field cannot reach another through its own contents.
A list or a group takes its whole value from one placeholder with nothing around it, no format
and no fallback: "tags": "{{item.Tags}}" fills the list with the array itself, and
"cta": "{{item.Link}}" a group with the object. What it resolves to is data. Each entry is checked
against the field, an entry that fails is left out rather than failing the block, the list is cut at
its max since the data is not the editor's to shorten, and one that comes up short of min drops
the block. Nothing in it is scanned for placeholders. A list or a group typed out in full binds
each string inside it like any string prop.
source loads one entry or a page of them and puts it in scope. repeat renders its content once
per row. pager draws prev and next links for the source it sits in, paged by the API. showIf
keeps its content only when a bound value has something, or equals what it names.
{ "type": "source", "props": {
"collection": "enrolments", "mode": "list", "pageSize": 20, "pageParam": "p",
"filterField": "Class", "filterValue": "{{query.class}}",
"content": [[
{ "type": "repeat", "props": { "empty": "No one yet.", "content": [[
{ "type": "text", "props": { "value": "{{item.Title}}" } }
]] } },
{ "type": "pager", "props": {} }
]]
} }
A filter narrows what the API already lets the reader see. It is never access control: who may read
which rows is decided in barakoCMS. A page reads at most eight sources, and a source at most fifty
rows a page.
mode: "all" is for a page that is the whole of a collection rather than a page of it, a changelog
say: the source reads every row, fifty to a request, up to 500 (MAX_ALL_ROWS). Its first request
is one of the page's eight reads, and the rest are made at once, out of 16 more a page's all
sources share between them (MAX_ALL_REQUESTS). A source past 500 rows, or past what the requests
cover, reads what it can and says so in the server log, and {{count}} stays what the collection
holds. A pager inside it draws nothing, and sums, distinct counts, groups and a filter bar cover
every row it read. A repeat draws every row its source read unless its limit names fewer. What
the rows of a repeat and the groups of a grouped source draw spends a budget of its own, 10,000
blocks (MAX_ROW_BLOCKS), rather than the page's 400, so a long list neither takes the blocks after
it off the page nor renders without end; rows or groups past it are left out and the log says how
many were drawn.
Counts, sums and groups. {{count.<collection>}} anywhere on a page is how many published
entries the collection has, for example {{count.posts}}. It is the delivery API's totalItems for
a page of one row, cached like every other read, and the public API lists published entries only, so
it is the number of rows the page could list. Inside a source, {{count}} on its own is how many
rows that source's filter matched, all of them and not just the page it read:
{ "type": "source", "props": {
"collection": "packages", "mode": "list", "pageSize": 1,
"filterField": "Category", "filterValue": "Auth",
"content": [[ { "type": "text", "props": { "value": "{{count}} auth modules" } } ]]
} }
A filtered count is a source rather than something like {{count.packages where Category=Auth}}
because a placeholder is paths, formats and fallbacks, and a filter value is free text: a value with a
space or a hyphen cannot be a path segment, and a placeholder that could carry a condition would be
the start of an expression language. filterField and filterValue already bind, so the value can
come from {{query.c}} or an item, and the count costs the one read the source makes anyway. Set
pageSize to 1 when the count is all the source is for.
Each collection counted is one of the page's eight reads, spent the first time a placeholder names
it and shared by every other placeholder that names it. Sources and counts draw on the same eight, in
the order the page is written. A count past the budget, one for a collection the tenant does not
have, and one whose read failed all render the fallback and are reported as no value.
{{sum.<Field>}} inside a source adds that field over the rows the source read, for example the
open issues across a roadmap's milestones. A number stored as text counts. A field with a word in
any row is not a sum and renders its fallback, and a source with no rows has no sums, so write
{{sum.Open ?? 0}} where zero is the right answer. A sum covers the rows read: fifty at most in
list mode, where a source that pages sums the page it is on, and up to 500 in all mode.
{{distinct.<Field>}} is how many different values that field holds among the same rows: the
repositories a list of issues spans, "16 issues across 3 repositories". Values compare as the text a
placeholder prints, each entry of a list field counts on its own, and a row with nothing in the field
adds nothing, so a field no row holds renders its fallback. Inside a group it counts that group's rows.
It counts only what the source read, so a source that matched more rows than it read (a page of a
longer list) has no distinct counts and renders the fallback: {{distinct.Repo ?? several}}. It
reads a top-level field; a dotted path such as Owner.Login renders the fallback.
groupBy names a field, and the source's content then renders once per distinct value of it among
the rows read, in the order first seen. groupOrder is a comma separated list of values to put
first, in that order; the rest follow in the order first seen. Inside each group {{group.key}} is
the value and {{group.count}} how many rows have it, and repeat and {{sum.X}} work over that
group's rows. {{count}} stays what the whole source matched. A row with nothing in the field is
kept, in a group whose key is empty, so {{group.key ?? Other}} names it. A pager inside a grouped
source renders nothing, since a page of rows is not a page of groups. One grouped source is one read,
where a source per value was one read each:
{ "type": "source", "props": {
"collection": "milestones", "mode": "list", "pageSize": 50,
"groupBy": "Repository", "groupOrder": "barakoCMS, barakoPress",
"content": [[
{ "type": "text", "props": { "value": "{{group.key}}: {{sum.Open}} open in {{group.count}} milestones" } },
{ "type": "repeat", "props": { "content": [[ { "type": "text", "props": { "value": "{{item.Title}}" } } ]] } }
]]
} }
All of it resolves on the server as the request's tenant, so a count or a sum never makes the browser call the API. A page that uses none of it reads and renders exactly what it did before.
Filter buttons. A filterBar inside a source draws one button per distinct value of field
among the rows the source read, after an "all" button (allLabel, which binds, so All {{count}}
works). The values come in the order first seen; order is a comma separated list to put first, and
a value it names that no row holds gets no button. separator splits a text field that holds several
values, such as 4.4.0, 4.3.0, and a list field gives one value per entry. A bar offers at most a
hundred values (MAX_FILTER_VALUES), the first hundred in that order, and a row carries only values
the bar offers. Fewer than two values is no choice, so the bar draws nothing.
{ "type": "source", "props": {
"collection": "packages", "mode": "list", "pageSize": 50,
"content": [[
{ "type": "filterBar", "props": { "field": "Category", "allLabel": "All {{count}}", "label": "Filter by category" } },
{ "type": "repeat", "props": { "content": [[ { "type": "text", "props": { "value": "{{item.Title}}" } } ]] } }
]]
} }
The rows stay server-rendered and the buttons read nothing. The binder marks each row's own blocks
with data-bp-filter, the bar's id, and data-bp-filter-values, its values, each written as
<id>:<value> with the value percent-encoded so a value with a space is one token. A row of a source
nested in another source's row belongs to both bars, so each attribute can hold two, and each bar's
rule reads only its own. The id is f<n>-<scope>, where the scope is the part of the page the bind
draws (body, header, footer, or index for a collection's index page, and scope in
bindBlocks options for a site that binds its own), so a bar in the header and an identical one in the
body never share an id, and the same page renders the same bytes every time. A click sets aria-pressed on the button and
data-bp-filter-value on the bar, and writes one rule that hides every row of that bar without the
value, with the value escaped as a CSS string and !important so it wins over a row's inline
display. A site writes no rule of its own, so a value nobody planned for still filters, and a
reader with no script sees every row. The buttons are toggles with aria-pressed inside a labelled
group, not a tablist, since the tab pattern promises arrow keys and panels this does not have. A row
with nothing in the field has no value, so any choice hides it.
The bar draws its buttons from the theme's colours. recipe names a style recipe for the row,
buttonRecipe one for each button and pressedRecipe one for the button that is pressed, each
replacing that look outright as a recipe does on a primitive; a button keeps cursor: pointer.
In a grouped source the bar is drawn once, ahead of the groups, and filters all of them. With
hideEmptyGroups, a group's own blocks carry every value its rows hold, so the same rule hides a
group that has no row left. Without it a group stays with its heading and no rows. Only the first bar
in a source counts. A grouped source honours only a bar at the top level of its content; one inside a
band there would repeat with every group, so it draws nothing and marks no row. The
values are those of the rows read, so a source that pages filters the page it is on.