Guides
Writing a theme
The theme settings cover colors and layout options. To go further, replace single files of the built-in theme, add your own CSS, or write a theme from scratch. All of it happens on the server.
Three levels
| You want to | Do this |
|---|---|
Tweak styles: fonts, spacing, colors beyond accent | Add a CSS file with an overrides folder |
| Change one part of the page, like the note header or the footer | Override that template |
| Change a word, or translate the theme to another language | Add a word file |
| A completely different look | Write a new theme |
Overriding theme files
An overrides folder has the same layout as a theme. Each file in it replaces the theme's file with the same path, and new files are added. Everything else comes from the theme, so you only copy what you change.
[[site]]
id = "garden"
theme = "garden"
theme_overrides = "/etc/kvist/theme" # a folder on the server
With Docker, put the folder next to docker-compose.yml and mount it into the container:
services:
kvist:
volumes:
- kvist-data:/data
- ./kvist.toml:/etc/kvist/kvist.toml:ro
- ./theme:/etc/kvist/theme:ro # add this line
Then run docker compose up -d and publish, so the site is rebuilt with your files.
The built-in theme's files are in the repository under themes/garden/. Copy the one you want to change into your overrides folder at the same path. The main parts:
| File | What it renders |
|---|---|
templates/base.html | The page skeleton: <head>, the three columns, scripts |
templates/note.html | A note: header, content, backlinks, contents and graph |
templates/home.html | The home page |
templates/partials/nav.html | The menu column |
templates/partials/list.html | The middle list column |
templates/partials/note-parts.html | Cards, growth stages, backlinks, contents list |
templates/partials/head-meta.html | Link-preview tags in <head> |
templates/partials/head-extra.html | Empty: your own additions at the end of <head> |
static/style.css | All styles |
Overridden files don't update
When kvist updates, your copies stay as they are. Override as few files as you can, and compare them with the new versions after an update.
Adding your own CSS
The lightest way to restyle the site is an extra stylesheet loaded after the theme's own, so you don't copy style.css at all. Two small files in your overrides folder do it:
:root {
--font: "Iowan Old Style", Georgia, serif; /* the theme's styles use CSS variables */
}
.note-header h1 { letter-spacing: 0; }
{{define "head-extra" -}}
<link rel="stylesheet" href="{{asset "custom.css"}}">
{{- end}}
The theme includes head-extra at the very end of <head> and leaves it empty, just for this. Your stylesheet loads after the theme's, so your rules win. {{asset "custom.css"}} gives the file's address, with a fingerprint that changes when the file does, so browsers never use an old copy. The same file is the place for extra meta tags or a script.
Useful variables to change: --bg, --fg, --muted, --border, --pane, --font, --font-mono and --radius. For dark mode, set them again under :root[data-theme="dark"] and in a @media (prefers-color-scheme: dark) block, as style.css does.
Changing the theme's words
The menu, dates and search speak the site's language. The theme keeps its words in i18n/<language>.toml, one file per language; the built-in garden theme has English, Swedish, German, French and Spanish. To change a word, or to add a language, put a file with the same name in your overrides folder. It only needs the words you change, the rest still comes from the theme:
planted = "Sådd"
tended = "Ompysslad"
For a language the theme doesn't have, start from a copy of themes/garden/i18n/en.toml in the repository, name it after the language code (nb.toml, pt.toml, …) and translate it. Words you leave out stay in English.
Previewing while you work
Run the preview server on your computer with a copy of your server's config, pointing theme_overrides (or theme) at your local folder:
kvist dev --dir ~/Documents/MyVault --config kvist.toml
It rebuilds and reloads the browser whenever a note or a theme file changes. A mistake in a template shows at the bottom of the page, while the last working version stays up. See the command line for how to get kvist.
A new theme
A theme is a folder:
mytheme/
theme.toml
templates/
base.html the page skeleton; fills {{block "main"}} and {{block "aside"}}
home.html the home page, /
note.html every note
folder.html folders without a folder note
tag.html /tags/<tag>/
tags.html /tags/
404.html the "not found" page
partials/*.html shared {{define}} blocks
static/ CSS, JavaScript, fonts, images
i18n/ the theme's words, one file per language (optional)
name = "mytheme"
version = "1.0.0"
license = "MIT"
model = [1] # content model versions this theme understands
[params] # defaults; sites change them in the settings note
accent = "#3f7d4e"
Put the folder in themes_dir (default: themes, next to where kvist runs) and set theme = "mytheme", or give a path: theme = "/etc/kvist/mytheme". In Docker, mount the folder like the overrides above. A folder in themes_dir named garden replaces the built-in one.
The quickest start is to copy themes/garden from the repository, rename it, and change it from there.
Templates
Templates use Go's html/template, which escapes text by context, so note titles can't break your HTML. Each page template is combined with base.html and all partials, and base.html is run with a page:
| Field | |
|---|---|
.Kind | home, note, folder, tag, tags or 404 |
.Title, .URL, .Description | For <title>, links and meta tags. Folder and tag pages get a description from what they list |
.Canonical | The page's preferred address, for <link rel="canonical">: the same as .URL, except on the home note's own page, which points to the home page |
.Note | The note, on note pages (and on the home page if it uses a note) |
.Folder, .Tag | The folder or tag, on their pages |
.Site | Everything published (below) |
.Features | .Math, .Mermaid, .Code: load KaTeX, Mermaid and highlighting styles only when the page needs them |
.SocialImage | On note pages without an image (or with an SVG one, which social media don't show): the address of a generated preview picture, if the theme turns on social_images in its [params] |
.Image | The picture to use for og:image: the note's image, else .SocialImage, else the site's image; empty if none can be shown. .ImageWidth and .ImageHeight are its size, 0 if unknown |
.NoIndex | True when search engines should leave the page out: unlisted notes, the 404 page and tag pages with fewer notes than tag_min_notes |
A minimal base.html and note.html:
<!doctype html>
<html lang="{{.Site.Config.Language}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · {{.Site.Config.Title}}</title>
<link rel="stylesheet" href="{{asset "style.css"}}">
{{- if .Features.Code}}<link rel="stylesheet" href="{{asset "syntax.css"}}">{{end}}
</head>
<body>
<header><a href="/">{{.Site.Config.Title}}</a></header>
<main>{{block "main" .}}{{end}}</main>
</body>
</html>
{{define "main" -}}
<article>
<h1>{{.Note.Title}}</h1>
<p>{{dateFormat "2 January 2006" .Note.Updated}}</p>
{{.Note.Content}}
{{- with .Note.Backlinks}}
<h2>Links to this note</h2>
<ul>{{range .}}<li><a href="{{.Source.URL}}">{{.Source.Title}}</a></li>{{end}}</ul>
{{- end}}
</article>
{{- end}}
What a theme receives
| Object | Fields |
|---|---|
.Site | Notes (every published note, sorted by address; private notes are never included, so there's nothing to filter), Root (the site's root folder; its subfolders are the top-level folders), AllTags, Tags (top-level), Home, Graph, Config |
.Site.Config | Title, Description, BaseURL, Language, Author, PrimaryNav and Nav (the settings note's primary_links and secondary_links, each a list of Title, URL), Params, Image, Favicon, Profile (Avatar, Bio, Links), Analytics (Provider, ID, Script, Endpoint) |
| A note | Title, URL, Description, Content (HTML), Created, Updated, Tags, Aliases, TOC, Links, Backlinks (Source, Context), Related (notes with the most in common), Folder, Params, Image, ImageThumb and ImageCard (small copies for lists and cards), Unlisted, Canvas (the page is a canvas, its Content a board of cards), WordCount, ReadingTime |
| A folder | Name, Path, URL, Notes (without unlisted notes), Children, Index (its folder note), Parent, Sort, Icon and IconText (from the folder note's icon: an icon name to draw, or an emoji to show as it is; at most one is set) |
| A tag | Name (e.g. projects/kvist), URL, Notes (including child tags' notes), Children, Parent |
A heading (TOC) | Level, Text, ID, Children |
The built-in theme draws a folder's icon with the folder-icon template in templates/partials/nav.html, and the icons themselves in the icon template next to it. To add icon names of your own, copy that file into your overrides folder and add a branch to icon. See Folder icons for the names it already knows.
A note's Params holds only the properties listed in the server's expose_frontmatter. To see all the data for a vault, write it out as JSON:
kvist build --dir ~/Documents/MyVault --config kvist.toml --out /tmp/site --emit-model model.json
The content model reference describes every field in detail.
Functions
| Function | Example | |
|---|---|---|
asset | {{asset "style.css"}} | Address of a file in static/, fingerprinted |
param | {{param "accent"}} | A theme setting: the site's value, else the theme's default |
recent | {{range recent 10}} | The most recently updated notes, without unlisted ones, the home note or folder notes |
newest | {{range newest 3 .Tag.Notes}} | Any list of notes, most recently updated first (0 for all) |
dateFormat | {{dateFormat "2 Jan 2006" .Note.Updated}} | Format a date: write 2 January 2006 the way you want dates to look |
isoDate | {{isoDate .Note.Created}} | For <time datetime> |
absURL | {{absURL .URL}} | Full address with the site's domain |
structuredData | {{structuredData .}} | The page described for search engines (schema.org JSON-LD), for <script type="application/ld+json">; empty on the 404 page |
markdownify | {{markdownify (str (param "footer"))}} | Markdown to HTML |
truncate | {{truncate 160 .Description}} | Shorten at a word boundary |
hash | art-{{hash .URL 8}} | A number from 0 to n−1 picked from a text, the same in every build, e.g. to give each note its own color |
default | {{default "Untitled" .Title}} | A fallback for empty values |
dict | {{template "card" (dict "Note" . "Big" true)}} | Pass several values to a template |
json | {{json .Site.Graph}} | A value as JSON |
t | {{t "search"}}, {{t "notes" 3}} | A word in the site's language, from i18n/ (above); plural forms pick by the count |
i18n | {{json (i18n "script")}} | A table of words, for a script |
propertyValues | {{range propertyValues "2 Jan 2006" .Note.Params.authors}} | A property as text: one per list item, dates formatted, [[links]] as their text |
| Text helpers | lower, upper, title, join, replace, contains, hasPrefix, str | |
| Numbers | add, sub |
Templates can't read files or reach the network.
Styling note content
A note's Content is ready-made HTML. These are the classes to style:
| Class | What |
|---|---|
a.internal-link, a.external-link | A link to another published note, or to another website |
span.link-unpublished | A link to a private or missing note. Style it as plain text, so visitors can't tell that a private note exists. |
div.embed, .embed-title, .embed-content | An embedded note or section |
div.callout[data-callout="tip"], .callout-title, .callout-content | Callouts; foldable ones use <details> |
a.tag, span.tag-hidden | Tags; the publish tags are hidden ones |
div.code-block[data-lang], pre.chroma | Highlighted code; colors come from syntax.css |
.code-title, .line.hl, .ln | A code block's title, its highlighted lines and its line numbers |
div.canvas, .canvas-board, .canvas-node, svg.canvas-edges | A canvas: cards placed by inline styles, arrows in SVG. The content model lists every class |
.math-inline, .math-display | Math, for KaTeX to render in the browser |
pre.mermaid | A diagram, for Mermaid to draw |
img.align-left, .align-right, .align-center | Images placed with |left, |right or |center |
mark, iframe.embed-pdf, iframe.embed-video, audio, video | Highlights, embedded files and YouTube or Vimeo players |
Every build also writes /search-index.json and /graph.json, which a theme's scripts can load for search and graphs, plus /index.xml (RSS), /sitemap.xml and /robots.txt. None of them include unlisted notes. Redirects for moved notes are added by the server, whatever the theme: a small page at each old address, and a map the built-in server reads from disk (_kvist/redirects.json, not served).
Good theme manners
- Work without JavaScript. Use scripts for extras like search, the graph and the theme toggle.
- Keep private notes invisible. Style
.link-unpublishedlike normal text, with no “locked” icons or hints. - Respect unlisted notes. The site's lists leave them out already; on their own page, add
<meta name="robots" content="noindex">when.NoIndexis true (it is for unlisted notes). - Describe pages for search engines. Use
.Canonicalfor the canonical link,.Imageforog:imageandstructuredDatafor JSON-LD, as the built-in theme'spartials/head-meta.htmldoes. - Load heavy things only when needed. Check
.Features.Mathand.Features.Mermaidbefore loading KaTeX or Mermaid. - Don't load from other websites. Put fonts and libraries in
static/, so visitors aren't tracked and the site keeps working. - Keep pages small. Don't list every note on every page; big gardens have thousands. The built-in theme shows only the current folder or group.
The theme reference with every detail, including caching and the built-in theme's settings, is in docs/themes.md.