Skip to content

Building a theme

Views

The Vue components that render your pages, and how Nibble picks one.

A view is a Vue single-file component under themes/<yours>/views/. It is rendered on the server and then hydrated in the browser, so visitors get HTML immediately and search engines see a complete page.

themes/almanac/
├── layouts/default.vue          the frame around every page
├── views/
│   ├── home.vue                 one view
│   ├── home.yml                 what that view needs — see Queries
│   ├── guides/index.vue
│   ├── guides/show.vue
│   ├── sets/quote.vue           one block of a replicator field
│   └── errors/404.vue
├── components/PostCard.vue      your own components
└── styles/theme.css

Which view renders a request

Nibble resolves the URL to an entry, a taxonomy term or a taxonomy index, then picks the first template that is set:

  1. the entry’s own template field, if it has one
  2. its blueprint’s template
  3. its collection’s template
  4. default

Terms use their taxonomy’s template, falling back to taxonomies/show.

That order means an unusual page can have its own view without needing its own collection.

Generate one already wired up:

bin/rails nibble:generate:view guides/index --collection=guides

It writes the view, its query sidecar, and prints the line to add to the collection.

What a view receives

Every view gets page — the record being rendered — plus whatever its sidecar asked for, plus shared props for the site, its globals and its navigation.

<script setup lang="ts">
import type { ViewProps, GuidesGuide } from '../../.nibble/types'
import { RichText, Image } from '@nibble'

defineProps<ViewProps['guides/show'] & { page: GuidesGuide }>()
</script>

<template>
  <article>
    <h1>{{ page.title }}</h1>
    <Image v-if="page.featured_image" :asset="page.featured_image" preset="hero" />
    <RichText :value="page.body" />
  </article>
</template>

Those types are generated from your blueprints, so page.body is known to exist and a typo is caught before the page is.

A view never fetches anything itself. If it needs more content, that goes in its query sidecar — which is what lets Nibble know exactly what a page depended on, and clear the right caches when any of it changes.

Layouts

layouts/default.vue wraps every view. A page can ask for another by setting a layout, which is how a landing page drops the site’s usual header without a second theme.

Blocks

views/sets/<name>.vue renders one set of a replicator field. An editor builds the page from blocks, and each block is drawn by the file of that name — a quote, a gallery, a call to action. See Theme helpers for the Blocks component that runs them.

Error pages

views/errors/404.vue and views/errors/500.vue render those responses, with the same layout and styling as everything else.

Highlighting code

Nibble renders a fenced code block as <pre><code class="language-ruby"> — the shape Prism, highlight.js and Shiki all look for — and stops there. Highlighting is the theme’s decision, because it is appearance, and a site that publishes no code should carry no highlighter.

The crumbs theme highlights with highlight.js, loading only the grammars a page actually uses:

npm install highlight.js -w @nibble-theme/<yours>
// after the HTML is in the DOM
const { default: hljs } = await import('highlight.js/lib/core')
hljs.registerLanguage('ruby', (await import('highlight.js/lib/languages/ruby')).default)
document.querySelectorAll('pre code[class*="language-"]').forEach((block) => hljs.highlightElement(block))

Nibble deliberately does not highlight on the server: the built-in option writes inline colours in a theme of its own choosing, which no stylesheet of yours could override.

Styles

styles/theme.css is the theme’s stylesheet, built by Vite. Themes are npm workspaces, so a theme can have dependencies of its own:

npm install <package> -w @nibble-theme/<yours>

Commit the lockfile afterwards — it is shared with Nibble, and an upgrade rebuilds it rather than installing from it, so a conflict there is harmless.