barakoCMS

Collections

Collections: any content type as a list and a detail page

A hospital has departments and doctors, a law firm practice areas and people. Each is a collection: a content type with a route, a field map, references to other collections, a sort, and whether it has a feed. The blog is three collections that defineConfig derives from types, fields and routes (post, author and category), and createBlogIndex, createBlogPost and createArchive are thin wrappers over the collection screens. A site configured the old way changes nothing: src/blog-wrappers.test.tsx renders every blog factory, the feed, the sitemap and the metadata, and compares them and the requests they make with test/blog-wrappers.golden.json, which was written before collections existed.

On a request-time site the map is the tenant's, from a json field Collections on its site settings, merged over the configured collections by key. A build-time site passes collections to defineConfig in the same shape:

{
  "departments": {
    "type": "department", "route": "/departments",
    "fields": { "title": "Name", "body": "About" },
    "noun": ["department", "departments"]
  },
  "doctors": {
    "type": "doctor", "route": "/doctors", "sort": "Name",
    "fields": { "title": "Name", "summary": "Specialty", "image": "Photo" },
    "references": { "Department": { "collection": "departments", "label": "in" } },
    "noun": ["doctor", "doctors"]
  }
}
Key What
type The content type. Required
route The index is served at the route and an item at route/slug. Absent, items are listed and never linked
fields title (required), slug, summary, body (markdown), date, image, imageAlt, featured, tags, url, photo, progress, progressCount, progressTotal, href. Each is a field name or a list tried in order; @createdAt and @updatedAt read the entry itself. photo is a portrait drawn above the title, image the wide one, and progress is a number from 0 to 100 that progressList draws. progressCount and progressTotal draw the same bar from two counts instead, for a source that gives those rather than a figure already worked out; progress wins when both are set. href is where a card links when that is not the item's own route: a collection filled by a sync usually carries the source's own URL and has no route on this site
references Reference fields, each naming the collection it points into and the word a card puts before the link. Resolved in the same request
sort Sent to the API, for example -PublishedAt
feed Whether createFeed(config, key) serves it
sitemap On unless false. A collection is paged to pageSizes.sitemap entries; the file stops at the standard's 50,000 URLs and says so in the log
index Whether the root catch-all serves an index at the route. On unless false; the derived author and category have it off, so /authors stays a 404 unless a route file mounts it. An object is the index's copy, and the index is on. See below
indexPage A site path such as /site/blog. The blocks of the page served there are drawn above the list on the index. See below
defaultAuthor The byline name on an entry that names no author, on its card and on its article page
pageSize, label, noun Items on the index, its heading, and how a count reads
colorBy A choice field whose option colours the item. See below
related What an item page lists under the item: "semantic" for the items of this collection closest to it by meaning, false for none. Unset, the first collection that references this one
readingTime Whether an item page shows a read time worked out from its body. Off unless true
layout How an item page is drawn: "list", the shell markup every collection has had, or "article", the reading column the blog's posts are drawn in. See below
tree Set when the collection is a manual rather than a flat list: sections, an order, nesting, products. See below

A settings entry that does not read as a collection is left out whole: a type or field that is not a plain identifier, a route that is not a plain site path, or no title field. post, author and category are replaceable by key like any other collection, since every read goes through the collection's own field map. A build-time collection mounted at / throws in defineConfig. When two routes match, the longer one wins.

A request-time site cannot add a route file per tenant, so the root catch-all from the pages section also serves every collection with a route: its index at the route, and an item one segment below. A route file wins where there is one. A collection's first segment is reserved from pages at the root.

A detail page lists the items of the first collection that references it, so /departments/cardiology lists its doctors, the way an author's archive lists their posts. listRelated(config, "doctors", department, { via: "Department" }) returns the same list; listRelated(config, post) still returns related posts by semantic search.

With related: "semantic" it lists the items of its own collection nearest it by meaning instead, so an agency's case study gets the band a post has. That needs the CMS AI module: without it the search answers nothing and the page draws no band at all, which is the same way the post page degrades. listRelatedItems(config, key, item) returns that list on its own.

The index's own words. An editor sets what a collection's index says in the collection's settings, rather than a theme writing a route file for it:

{
  "blog": {
    "type": "post", "route": "/blog", "fields": { "title": "Title" },
    "index": {
      "eyebrow": "Changelog", "heading": "What shipped",
      "lede": "Every release, newest first.",
      "empty": "Nothing shipped yet.", "unavailable": "The changelog is resting. Try again shortly."
    },
    "indexPage": "/site/blog",
    "defaultAuthor": "The barakoCMS team"
  }
}

eyebrow is a short line above the heading, heading replaces label, lede replaces the site's tagline under it, empty is what an index with nothing published says, and unavailable what one whose read failed says, each in place of the labels lines that say it otherwise. Every line is optional, one of the wrong kind or too long is dropped on its own, and a route file's own heading still wins. indexPage draws a page's blocks above the list, so the index is composed like any page and the route stays the collection's. With defaultAuthor, an entry whose first reference (the byline) did not come back, or a collection with no reference at all, is signed with that name after labels.by. Set none of the three and the index and the byline draw what they always did.

A page named by indexPage only holds data, the way a header or footer region page does: it answers 404 at its own path and is left out of the menu and the sitemap.

Filtering. listCollection(config, "doctors", { filter: { Department: "cardiology" } }) takes a reference field by the target's slug and any other field by the value it holds, a choice field by its option. The API takes five filters. createCollectionIndex(config, key, { filter }) takes the same, and the collection block has filterField and filterValue.

A style per option. With colorBy: "AreaOfFocus" on a project collection, the site settings OptionStyles entry says how each option of that field is shown:

{
  "OptionStyles": {
    "project.AreaOfFocus": {
      "Providing clean water": { "tone": "sky", "icon": "location", "label": "Water" }
    }
  }
}

tone names a colour from Colors, a theme slot, or a colour written out, and each card, item page and collection block item carries it as a left border. icon is one of the engine's icon names, and label is the word a visitor reads in place of the option's own value; the value stays on the element as data-option, where a site's own CSS can still find it. A tone that does not resolve to something readable as a colour is dropped, a label past 40 characters is dropped, and an icon name nothing draws draws nothing, each on its own rather than losing the whole entry.

OptionColors is the same thing said shorter, { "project.AreaOfFocus": { "Providing clean water": "sky" } }, an option whose style is a tone and nothing else. A tenant that saved colours keeps them, and a style for the same option wins field by field. A build-time site passes optionStyles, or optionColors, in the same shapes.

Card and ItemView are exported for a site that wants its own page, and Card still takes a post. getItem, listCollection and getGlobals(config), the tenant's settings entry as stored, are exported too.

A long-form layout. layout: "article" draws an item as a reading column: a back link, the title and standfirst, a byline from the collection's first reference, the date, a read time worked out from the body, the other references, the tags, the cover image, the prose and a band of neighbours under it. That is the page the blog's posts have always had, and it is a collection setting rather than a blog screen, so a law firm's briefings or a newsroom's features get it by asking. Styled inline from the theme, so it looks right whether or not the consumer imports barakopress/styles.css. PostView is a wrapper over the same layout and is @deprecated for 1.0.0.

Docs: a collection as a tree. A manual is a collection plus four fields saying where each page sits. tree names them, and the settings beside them say what the sidebar and the switcher show:

{
  "docs": {
    "type": "doc", "route": "/docs", "label": "Documentation", "layout": "article",
    "fields": { "title": "Title", "slug": "Slug", "body": "Body" },
    "tree": {
      "section": "Section", "order": "Order", "parent": "Parent", "product": "Product",
      "sections": ["Getting started", "Guides", "Reference"],
      "searchPath": "/docs",
      "editPath": "Source",
      "editBase": "https://github.com/owner/repo/edit/master/",
      "products": [
        { "key": "cms", "label": "barakoCMS", "href": "/docs" },
        { "key": "press", "label": "barakoPress", "href": "/docs/press" }
      ]
    }
  }
}
Key What
section The field holding the heading a page is grouped under
order The field holding its position in that section. A page with none comes after those with one
parent The field holding the slug of the page it hangs under, or a reference to it. A parent nobody has leaves the page at the top of its section rather than dropping it
product The field naming the product it documents, matched against a product's key. One field name and not a list, since this one goes into an API filter
sections The sections in the order the sidebar shows them. One not named here follows those that are. Named rather than worked out, because no ordering of the pages says which section comes first
products What the switcher offers: a key, the word a reader sees, and where it goes. A destination that is not a site path or an http URL is dropped
searchPath Where the search box submits, and whether one is drawn at all. Unset, no box, because only the site knows which of its routes reads the query
editPath The field holding the page's path in whatever repository it is written in. Its slug when unset
editBase Where "edit this page" points, with that path appended. Unset, no such link is drawn
limit The most pages read to build the tree. 500 unless set, and 500 is the ceiling as well as the default
variant How the parts are laid out. See "Styling the tree" below. Every part is today's layout when unset
searchIndex true to search the tree's titles and headings in the page as the reader types. The box is drawn whenever this is set; with searchPath too, a reader with no script submits there
icons { "search": "#id", "chevron": "#id" }: symbols already on the page (a site's own sprite) for the compact search box's magnifier and the closed disclosure's chevron. A value that is not # and a name is dropped, and the engine draws its own

A product may also carry a note, a few words drawn after its label in the list switcher ("on GitHub").

An item page in such a collection draws the sidebar with the page being read marked, the switcher, a search box, previous and next from the tree's reading order, and the edit link. The sidebar is a details element, so it collapses on a phone with no script. collectionTree(config, key, { product }) and treeNeighbours(order, slug) return the same tree and the same neighbours for a site's own page.

An item links where the collection's href field says it is read, when the collection maps one and it is a path on this site, and at its route and slug otherwise: the sidebar, previous and next, the in-page index and the sitemap all follow it (treeItemHref). An absolute address is not followed. Slugs are unique across a tenant, so a manual whose products each have a quickstart keeps cms-quickstart as the slug and names /docs/cms/quickstart in the field. The route serving that path is the site's own. A site drawing the sidebar on a page the tree does not hold, an index, gives TreeAside or TreeSidebar a summary ({ line, title }) for the closed control to say.

Search. searchCollection(config, key, query) goes through barakoCMS's /api/public/{type}/search, which matches only over the fields a type publishes, so a draft or a field held back from public delivery can never come back. createCollectionIndex(config, key, { search: true }) answers ?q= with what matched instead of the index; that reads the query, so the route is dynamic and output: "export" refuses it, which is why it is off unless asked for. The box is a form, the results are links, and the keyboard handling on top ("/" to focus, the arrow keys to walk the results, escape to clear) is the one client component in the package. The root catch-all answers ?q= on a collection index too, but only where the route may be dynamic: a request-time site rewrites to a kept route, and a kept route asking for the query fails rather than bailing out, so there the index lists and a page of blocks holding the search block is where a reader searches.

Which is why searchPath exists rather than the box pointing at the collection's own route. Only the site knows which of its routes reads the query, and a box submitting somewhere that ignores q sends a reader to an unfiltered index that looks like a search which matched everything. Name the route that answers, or name nothing and get no box.

As blocks. docsSidebar, docsSwitcher and search draw the same three on a page of blocks, each taking a collection key. search takes a bindable query, so a landing page binds {{query.q}} and the route file passes the query with createPage(config, blocks, { query: true }). docsSwitcher takes a variant, tabs or list, and follows the collection's own when it is left unset.

Styling the tree. A site restyles the tree screens without drawing them itself, three ways.

Tokens. Every colour, gap, padding, margin, radius, font size, weight and line height in them is read as var(--t-tree-<name>, <today's value>). Letter spacing, and the flex bases that decide when a row wraps, are fixed. A token named in the tenant's Tokens setting (or theme.tokens) lands on the root as --t-<name>, so "Tokens": { "tree-link-current-bg": "#FDEBD3" } restyles the current page's row and nothing else. A token in the settings is held to a colour, a length or a font stack; a weight or a unitless line height is set from the site's stylesheet under the same name (:root { --t-tree-link-weight: 500 }). A site that names none draws what it drew before, pixel for pixel.

Token Default What
tree-gap space.lg Between the sidebar and the page, plain
tree-shell-width, tree-shell-pad-y, tree-shell-pad-x layout.wide, space.lg, layout.gutter The shell's width and padding, plain
tree-edge hairline The rules between the columns and above them, boxed
tree-min-height 0px The shell's least height, boxed
tree-sidebar-width 280px The sidebar column (its most, plain; its width, boxed)
tree-sidebar-bg, tree-sidebar-pad-y, tree-sidebar-pad-x surface, space.lg, space.md The sidebar column, boxed
tree-body-pad-y, tree-body-pad-x space.xl, space.xl The page column, boxed
tree-aside-gap space.md Between the switcher, the search box and the sidebar
tree-section-gap space.md Between sections, and between the list switcher and the sections
tree-label-gap, tree-label-size, tree-label-ink space.xs, text.meta, muted Section labels, the list switcher's label and the rail's heading. The size is also the search box's label and the phone control's
tree-summary-bg, tree-summary-edge, tree-summary-ink, tree-summary-radius, tree-summary-space, tree-summary-pad-y, tree-summary-pad-x surface, hairline, muted, radii.control, space.sm, 10px, 12px The sidebar's control on a phone, and the space under it while open
tree-summary-pad-y, tree-summary-pad-x, tree-summary-min-height, tree-summary-gap, tree-summary-line-gap 10px, 12px, 0px, space.sm, 3px The closed disclosure's control
tree-summary-title-size, tree-summary-title-weight, tree-summary-title-ink text.small, 700, ink The page it names
tree-summary-action-size, tree-summary-action-weight, tree-summary-action-ink, tree-summary-action-gap, tree-summary-icon-size text.small, 600, accentInk, 6px, 13px Its "Contents" and "Close", and the chevron
tree-link-gap, tree-indent 0px, space.md Between rows, and a child's indent
tree-link-pad-y, tree-link-pad-x, tree-link-radius, tree-link-size, tree-link-leading 6px, 10px, radii.control, text.small, 1.45 A row in the sidebar, and a product in the list switcher
tree-link-ink, tree-link-weight secondaryInk, inherit A row that is not the page being read
tree-link-current-bg, tree-link-current-ink, tree-link-current-weight accentTint, accentInk, 600 The page (or product) being read
tree-note-gap, tree-note-size, tree-note-weight, tree-note-ink 6px, text.meta, inherit, muted A product's note
tree-tab-gap, tree-tab-pad-y, tree-tab-pad-x, tree-tab-radius, tree-tab-size, tree-tab-weight space.xs, 6px, 12px, radii.pill, text.small, 600 The tabs switcher
tree-tab-bg, tree-tab-edge, tree-tab-ink, tree-tab-current-bg, tree-tab-current-ink surface, hairline, secondaryInk, accent, inverseInk The tabs, and the current one
tree-search-bg, tree-search-edge, tree-search-ink, tree-search-radius, tree-search-size, tree-search-pad-y, tree-search-pad-x surface, hairline, ink, radii.control, text.small, 9px, 12px The search box
tree-search-label-gap, tree-search-label-ink space.xs, muted Its label
tree-search-results-gap, tree-search-hit-gap, tree-search-hit-radius, tree-search-hit-ink, tree-search-hit-weight, tree-search-empty-ink, tree-search-hit-pad-y, tree-search-hit-pad-x space.sm, 2px, radii.control, ink, 600, muted, 7px, 10px Its results
tree-search-height, tree-search-gap, tree-search-icon-size 36px, 9px, 13px The compact well. It also reads tree-search-bg (pageBg here), tree-search-radius, tree-search-pad-x, tree-search-size (text.meta here), tree-search-ink and, for the well's own ink, tree-search-label-ink
tree-search-key-pad-y, tree-search-key-pad-x, tree-search-key-radius, tree-search-key-bg, tree-search-key-edge, tree-search-key-size, tree-search-key-weight 2px, 6px, 6px, surface, hairline, text.meta, 700 The "/" key hint
tree-search-panel-gap, tree-search-panel-pad, tree-search-panel-bg, tree-search-panel-edge, tree-search-panel-radius, tree-search-panel-shadow 6px, 6px, surface, hairline, radii.panel, 0 10px 24px -12px rgba(16,18,35,.25) The compact box's floating results
tree-search-hit-size, tree-search-empty-pad-y, tree-search-empty-pad-x text.small, 8px, 10px A compact result, and the line saying nothing matched
tree-rail-width, tree-rail-pad-y, tree-rail-pad-x, tree-rail-label-gap, tree-rail-gap layout.columnMin, space.xl, space.md, space.sm, 2px The rail (its padding only when boxed)
tree-rail-link-pad-y, tree-rail-link-pad-x, tree-rail-link-size, tree-rail-link-weight, tree-rail-link-ink, tree-rail-link-edge 6px, 10px, text.small, inherit, secondaryInk, hairline A heading in the rail
tree-pager-top, tree-pager-gap, tree-pager-pad-y, tree-pager-pad-x, tree-pager-radius, tree-pager-bg, tree-pager-edge space.lg, space.sm, 16px, 18px, radii.panel, surface, hairline Previous and next
tree-pager-label-size, tree-pager-label-ink, tree-pager-title-gap, tree-pager-title-size, tree-pager-title-weight, tree-pager-title-ink text.meta, muted, space.xs, text.small, 600, ink Their words
tree-edit-top, tree-edit-ink, tree-edit-size space.md, muted, text.meta "Edit this page"

Variants. What a token cannot say, because it changes which elements are drawn or where they sit, is the tree's variant, and a variant prop on the component:

"tree": { "variant": { "switcher": "list", "sidebar": "boxed", "rail": true, "pager": "halves" } }
Part Values What
switcher tabs (default), list A row of pills above the search box, or a labelled column of rows inside the sidebar above the sections, under the search box, folding away with the pages on a phone
sidebar plain (default), boxed The sidebar beside the page in the wide column, or the page split edge to edge with the sidebar a surface column and a hairline between. boxed stacks on a phone
rail false (default), true An "on this page" column of the item's second level headings at the inline end, linking to the ids its body renders. Hidden below 64rem
pager wide (default), halves Previous and next taking the room there is and wrapping on a phone, or two halves that never wrap, next on the right even with no previous
search box (default), compact A labelled input with its results under it, or one well holding a magnifier, the input and a "/" key hint, the input named by aria-label with no label on screen, and the results in a panel floating over what follows. "/" focuses either
disclosure open (default), closed On a phone, the sidebar open under a "Contents" control, or closed until tapped, the control naming the product, the section and the page being read, with "Contents" and a chevron that turn to closeContents and point up while open. Above the phone breakpoint both show the whole sidebar and no control. closed needs no script: the content is shown above the breakpoint through ::details-content, and a browser without it keeps the control there too, so the sidebar is one tap away rather than gone

A value the engine does not know is today's layout for that part.

Classes. Each part carries a class a stylesheet can reach, for what neither a token nor a variant covers (a hover, a transition): bp-tree-shell (and bp-tree-shell-boxed), bp-tree-aside, bp-tree-body, bp-tree-rail, bp-tree-sidebar, bp-tree-summary, bp-tree-sidebar-body, bp-tree-sections, bp-tree-section, bp-tree-section-label, bp-tree-list, bp-tree-item, bp-tree-link, bp-tree-link-current, bp-tree-switcher (with bp-tree-switcher-tabs or bp-tree-switcher-list), bp-tree-switcher-label, bp-tree-tab, bp-tree-tab-current, bp-tree-product, bp-tree-product-note, bp-tree-search, bp-tree-search-label, bp-tree-search-input, bp-tree-search-results, bp-tree-search-index, bp-tree-search-hit (a result a route answered with), bp-tree-search-empty, bp-tree-rail-nav, bp-tree-rail-label, bp-tree-rail-link, bp-tree-pager, bp-tree-pager-link, bp-tree-pager-prev, bp-tree-pager-next, bp-tree-pager-label, bp-tree-pager-title, bp-tree-pager-empty, bp-tree-edit, and for the variants bp-tree-search-compact, bp-tree-search-box, bp-tree-search-icon, bp-tree-search-key, bp-tree-nav-closed, bp-tree-summary-closed, bp-tree-summary-group, bp-tree-summary-title, bp-tree-summary-action, bp-tree-summary-show, bp-tree-summary-hide and bp-tree-summary-chevron. Every label paragraph (a section's, the list switcher's, the pager's, the rail's) also carries bp-label, so a rule aimed at running text can skip them all with :not(.bp-label). An index entry carries no class of its own, to stay small: it is .bp-tree-search-index li > a, holding a span with the words that matched and, for a heading, a second span naming its page. The styles are inline, so a stylesheet rule that sets a property the part already sets needs !important; a token does not. The index's entries are the exception: they are styled from one stylesheet scoped to their box, so an ordinary rule of the same or higher specificity reaches them.

The search index. treeSearchIndex(tree) lists every page with a route and then its second level headings, in reading order, each heading linked to its anchor. It is built once per tree read and shared by everything drawn from that read, and a body's headings (itemHeadings(item), from markdownHeadings in barakopress/markdown) are tokenised once per distinct body and kept, so a static build of a manual of n pages lexes each body once rather than n times. With searchIndex, the box draws the index into the page hidden and its client code shows the entries holding every word typed, eight at a time.

What that costs: an entry is a list item, a plain link and its words, styled from one stylesheet, so it is its href and its text plus about 50 bytes. The index is part of the server render, so Next carries it in the page's payload as well as its HTML, like any other server markup; it is never also handed to the client component as props. At most 2000 entries (TREE_INDEX_LIMIT) go into a page. Past that it is headings that are left out, never a page: every page's title is counted first and headings fill the rest in reading order, and the server log says once which manual lost how many. A body gives at most 100 headings (HEADINGS_PER_BODY), and what is kept between reads is those headings under a digest of the body, never the body.

The results are put away, with what was typed kept, when focus leaves the box, when something outside it is pressed, when one of them is followed, and on Escape; they come back when the box has focus again. Enter goes to the first match. With no searchPath there is no form at all, so Enter can never reload the page with the query: with script the box answers in the page, and a reader with no script gets a field that does nothing, not one that reloads the page and empties itself.