Building a theme
Theme components
The components and composables @nibble gives every theme — rich text, images, blocks, pagination, SEO, shared props and forms.
@nibble is the theme runtime: a handful of components and composables for the things every theme needs. This
guide shows each one as Tidewater’s theme uses it.
After reading this guide, you will know:
- How to render rich text and Markdown safely.
- How to render images at the right size.
- How blocks, pagination and SEO tags are drawn.
- How to read globals and menus from anywhere.
- How to build a working form.
import {
RichText, Image, Blocks, Pagination, SeoHead, PreviewBar,
useSite, useGlobals, useNavigation, useLocale, useNibbleForm,
} from '@nibble'
1. Rich text
<RichText :value="page.body" />
A rich_text field is stored as structured data and rendered to HTML on the server, through an allowlist that strips
script elements, on* attributes and javascript: URLs. A markdown field is rendered on the server the same
way. RichText puts the result on the page, and draws any blocks embedded in the text with their set views.
Caution
Do not render content with v-html yourself. RichText is where the sanitising is tested; a hand-rolled
v-html on a field is where a script tag written by someone with an editor account reaches every visitor.
2. Images
An assets field names the size it is for:
- handle: featured_image
field: { type: assets, max_files: 1, alt: required, preset: hero }
and the view renders the value it receives:
<Image :image="page.featured_image" sizes="(min-width: 64rem) 64rem, 100vw" />
| Prop | |
|---|---|
image |
the asset value: url, alt, width, height and, when the preset has one, srcset |
sizes |
how wide the image is drawn — defaults to 100vw |
loading |
lazy (the default) or eager for the first image on the page |
The image is served through Nibble’s own transform endpoint at the preset’s size, cropped around the focal point an editor set, and cached by content — re-cropping gives it a new URL rather than a stale one. Alt text comes from the asset, so it is written once and right everywhere.
Tip
Give the hero image on a landing page loading="eager". It is the one image a visitor sees first, and lazy
loading it only delays it.
Presets are named sizes in config/nibble.yml; card, hero, content and og exist out of the box. See
Configuration.
3. Blocks
<Blocks :blocks="page.blocks" />
Each block is drawn by views/sets/<set handle>.vue with the set’s fields as props. A set with no view renders
nothing. See Views and layouts.
4. SEO and previews
Every layout starts with:
<SeoHead />
<PreviewBar />
Nibble writes a page’s Open Graph and Twitter tags, its structured data and any verification tags into the HTML it
sends. SeoHead looks after the tags that change as a visitor moves between pages — the title, description,
canonical link and robots — so they stay right after the first page too. All of it comes from the entry’s SEO
fields, falling back to its title, excerpt and featured image, and to the SEO global. See
SEO, sitemaps and feeds.
PreviewBar shows only when an editor is previewing a draft, so nobody mistakes a draft for the live page.
5. Pagination
<script setup lang="ts">
import { Pagination } from '@nibble'
import type { ViewProps } from '@site/types'
import PostCard from '../../components/PostCard.vue'
defineProps<ViewProps['posts/index']>()
</script>
<template>
<PostCard v-for="post in posts.data" :key="post.id" :post="post" />
<Pagination :meta="posts.meta" />
</template>
Pagination takes the meta of a paginated query and links each page with the URL
parameter it declares — param if it is not page.
6. Forms
A form’s definition comes from the view’s sidecar (from: form:demo), and useNibbleForm does the rest: values,
posting, validation errors, the honeypot, the CAPTCHA and the success message.
<!-- site/themes/tidewater/components/DemoForm.vue -->
<script setup lang="ts">
import { useNibbleForm, type NibbleForm } from '@nibble'
const props = defineProps<{ form: NibbleForm }>()
const demo = useNibbleForm(props.form)
</script>
<template>
<p v-if="demo.sent.value" role="status">{{ demo.message.value }}</p>
<form v-else :action="form.action" method="post" novalidate @submit="demo.submit">
<p v-if="demo.errors.value.base" role="alert">{{ demo.errors.value.base }}</p>
<div v-for="field in form.fields" :key="field.handle">
<label :for="field.handle">{{ field.display }}</label>
<input :id="field.handle" v-model="demo.values[field.handle] as string" :name="demo.fieldName(field)" />
<p v-if="demo.errors.value[field.handle]" role="alert">{{ demo.errors.value[field.handle] }}</p>
</div>
<button type="submit" :disabled="demo.processing.value">Book my demo</button>
</form>
</template>
From useNibbleForm |
|
|---|---|
values |
a reactive map of field handle to value |
errors |
messages by field handle, and base for the form as a whole |
processing |
true while the submission is in flight |
sent, message |
whether it succeeded, and the success message |
submit, reset |
send it; clear it |
fieldName(field) |
the input name Nibble expects |
setFiles, accept |
for files fields |
Warning
This short example renders every field as a text input and leaves out the CAPTCHA widget, so it only works for a
form without captcha: true. The theme you copied from crumbs has components/ContactForm.vue, which handles
every public fieldtype, file uploads, the honeypot and the CAPTCHA. Start from it rather than from scratch.
7. Shared props
Every page receives the site’s globals and menus. Read them anywhere, without threading props through:
<script setup lang="ts">
import { useGlobals, useNavigation, useSite } from '@nibble'
const site = useSite()
const company = useGlobals<{ name: string; tagline?: string }>('site')
const footer = useNavigation('footer')
</script>
| Composable | Returns |
|---|---|
useSite() |
the locale, the page’s URL, its declared parameters, every global and every menu |
useGlobals(handle) |
one global’s fields |
useNavigation(handle) |
one menu’s links: title, url, children |
useLocale() |
the page’s locale code |
All four return computed refs, so they stay current as a visitor navigates.