Skip to content

Building a theme

Queries

The YAML sidecar beside a view that fetches everything the page needs — sources, conditions, sorting, pagination, search and forms.

A view may have a .yml file beside it with the same name: its query sidecar. Each key becomes a prop, and each value is a query Nibble runs before rendering.

After reading this guide, you will know:

  • Why a view never fetches its own content.
  • Every source a query can read from, and every key it can use.
  • How to filter, exclude, sort, paginate and relate.
  • How to refer to the page being rendered, the URL’s parameters and the current time.
  • How mistakes in a query are caught.

1. Why a sidecar

Because Nibble runs the queries, it knows exactly what a page depended on. It caches the page against those things, and publishing any of them clears exactly the pages that used it. A view that fetched for itself would give all of that up.

Every Tidewater page ends with the three newest posts and the blog’s topics, so the view that renders pages asks for them:

# site/themes/tidewater/views/pages/show.yml
latest:
  from: entries:posts
  sort: published_at:desc
  limit: 3
  fields: [title, url, excerpt, published_at]
topics:
  from: terms:topics
  fields: [title, url]

The view receives latest and topics as props, typed for it in ViewProps['pages/show'].

2. Sources

from is required; everything else is optional.

from Reads
entries:<collection> published entries of a collection — rows or files
terms:<taxonomy> terms of a taxonomy
search:<index> results from a search index, for the query in q
form:<handle> a form’s definition, for rendering it — see Forms

Only published content is ever returned to a public page.

3. The keys

Key What it does
where conditions a record must match
not conditions that exclude a record
sort field:asc or field:desc, or a list of them — a column or any field of the blueprint
limit, offset a fixed slice
paginate { per_page, param } — pages, driven by a URL parameter
include relationship fields to load in full rather than as references
fields only these fields — smaller pages, fewer queries; parent adds the title and address of the page above
locale a locale other than the page’s
q the search terms, for a search: source

paginate and limit cannot be combined. per_page is capped at 100.

4. Conditions

where and not take a map of field to condition:

where:
  published_at: { lt: $now }
  blueprint: { in: [post, announcement] }
  topics: { in: $entry.topics }
not:
  id: $entry

Relationship conditions compare IDs, which is why they are usually written with a variable rather than by hand.

On Operators
columns — id, title, slug, uri, status, published_at, position, author_id, blueprint, template, … eq, ne, in, lt, lte, gt, gte, null, prefix
relationship fields — entries, terms in, all, none

A bare value means eq for a column and in for a relationship.

Warning

Only columns and relationship fields can be filtered. A condition on an ordinary field — featured: { eq: true } — is refused, because the values are not indexed and the query would have to read every entry. Model a flag you need to filter on as a taxonomy, or as a relationship.

5. Variables

A value starting with $ is filled in when the page renders:

Variable Is
$entry the entry being rendered; $entry.topics is one of its fields
$term the term being rendered
$set the block being rendered, in a block’s own sidecar
$params.<name> a URL parameter the sidecar declares
$now the current time
$locale the page’s locale

On its own, $entry or $term means the record’s ID. That makes Tidewater’s “related posts” a few lines:

# site/themes/tidewater/views/posts/show.yml
related:
  from: entries:posts
  where:
    topics: { in: $entry.topics }
  not:
    id: $entry
  limit: 3

Other posts in any of this post’s topics, not this one, at most three.

6. Pagination

# site/themes/tidewater/views/posts/index.yml
params: [page]
posts:
  from: entries:posts
  paginate: { per_page: 12 }

A paginated prop arrives as { data, meta }: the records, and meta with current_page, per_page, total and last_page. The Pagination component takes meta and draws the links — see Theme components.

Add scroll: true and the next page appends to the one already shown instead of replacing it, which is what Inertia’s InfiniteScroll needs to load more as a reader nears the end:

releases:
  from: entries:changelogs
  paginate: { per_page: 10, scroll: true }
<InfiniteScroll data="releases" only-next preserve-url>
  <article v-for="release in releases.data" :key="release.id">…</article>
</InfiniteScroll>

Important

List every URL parameter a sidecar reads under params. Nibble ignores parameters a sidecar has not declared, which is what stops ?utm_source=… from creating a new cached copy of every page.

A search page reads the query from the URL:

# site/themes/tidewater/views/search.yml
params: [q, page]
results:
  from: search:site
  q: $params.q
  paginate: { per_page: 12 }

Results come back most relevant first, each with a search_snippet: a few words around the match, the match in <mark>. A collection joins an index with its own search: key — see Collections.

where narrows a search to some of its collections, so one index serves a site-wide search and a docs-only one; not, sort and include do not apply:

results:
  from: search:site
  q: $params.q
  where: { collection: $params.section }

A blank section searches everything. fields keeps results small, and parent — the title and address of the page a result sits under — is there only when fields asks for it:

  fields: [title, uri, collection, parent]

Searching as you type

The search view is the whole contract: a form that sends ?q= to its page. To show results while someone types, ask that page for its results alone, with an Inertia partial request, instead of adding an endpoint:

import { usePage } from '@inertiajs/vue3'

const page = usePage()

async function results(q: string) {
  const response = await fetch(`/search?q=${encodeURIComponent(q)}`, {
    headers: {
      'X-Inertia': 'true',
      'X-Inertia-Version': page.version ?? '',
      'X-Inertia-Partial-Component': 'theme/search',
      'X-Inertia-Partial-Data': 'results',
    },
  })
  return response.ok ? (await response.json()).props.results.data : []
}

Wait for a pause in typing before asking, and cancel the request the next keystroke replaces. The answer is the same query the page runs, published content only, so there is nothing new to secure.

8. Mistakes are caught, not ignored

An unknown key, an unknown field, an operator a field does not support, or a variable that does not exist is an error — at render time in development, and in bin/rails nibble:check, which reads every sidecar in the theme:

✗ site/themes/tidewater/views/posts/show.yml: query 'related' where.featured: isn't filterable (only columns and relationship fields are)

A misspelled sort fails your check rather than quietly sorting by nothing in production.