Search
Search runs in the browser, against a JSON index written at build time. There's no search endpoint, so queries cost the server nothing.
Each build writes /search.json alongside the feeds and the sitemap. The theme’s search box downloads it the first time someone focuses the box, then ranks results locally as they type. Because it’s just another built file, static exports get search too.
Search needs trunkcms 0.2.0 or later. Try it in the header of this page, or press /.
Turning it on
Tick Search box on the site on the admin Settings page, or add this to site.yaml:
search:
enabled: true
Search is off unless site.yaml turns it on, so upgrading doesn’t change existing sites. Initialize site turns it on for new ones. Once it’s on, the admin dashboard shows the index size, along with how many documents and distinct words it holds.
What’s indexed
| Source | Weight |
|---|---|
| Post tags | ×3 |
| Post summary | ×2 |
| Post title and body | ×1 |
| Page title and body | ×1, unless pages: false |
Drafts and scheduled posts are never indexed. The index is public, so it’s built from the same list as the feeds. A scheduled post joins the index on the rebuild that publishes it.
Body text is the rendered HTML with tags stripped, so Markdown syntax and link URLs aren’t searchable, but code blocks are.
Options
All of these live under search: in site.yaml, and all of them are on the Settings page too.
search:
enabled: true
pages: true # index pages as well as posts (default true)
stopwords: [because] # added to the built-in list for the site's language
min_kw_length: 2 # shortest word indexed (default 2)
keep: [go, ai, ui, js] # always indexed, even if short or a stopword
title_boost: 1 # a title match is worth this many best body matches; 0 = off
Words
Text is lowercased and Unicode-normalized with accents and apostrophes removed, so “Café’s” is indexed as cafes and matches a search for cafe's, Cafés or cafes. It’s then split on anything that isn’t a letter or a digit. Queries are split by the same rules in the browser.
Words shorter than min_kw_length, and stopwords, are dropped unless they’re listed in keep. The built-in English list holds only function words like the, and and which. It deliberately leaves out short words that are often topics (go, ai, ui, os, js) and common page names (about). Other languages have no built-in list yet, so they rely on ranking alone.
Ranking
Results are ranked with BM25, summed over the words in the query:
- Rare words count for more. A word that appears in two posts says more about them than one that appears in all of them.
- Repetition saturates. The tenth mention of a word adds far less than the second, and long posts are scaled down so they don’t win just by being long.
- The last word matches as a prefix while you’re typing, so
webhalready finds webhook. Prefix matches score a little lower than exact ones. - Ties go to the newer post.
title_boost decides how much a word in the title is worth. At the default of 1, a title match is worth as much as the most any amount of body text can score. So a short post titled “Docker is great” ranks above every post that only says docker in its body, however many times. A post that matches more of the query’s words can still win: for docker compose, a post mentioning both beats one with only Docker in its title. At 2, a title match on one word also outweighs matching an extra word. 0 turns the bonus off.
Size
Measured on 300 real posts of about 1,000 words each, the index is roughly 530 KB raw and 160 KB gzipped, with around 11,000 distinct words. Expect 150–300 KB gzipped for 300 posts, depending on how varied the writing is.
The stopword and length settings barely move that number (at most about 12%), because most of the index is the long tail of rare words. Treat them as settings for result quality, not size.
The index is served at a stable URL with the usual max-age=60 and an ETag, so a repeat visit is a 304. It isn’t content-hashed like theme assets: it changes on every save, and a page cached from a few builds back would otherwise point at an index that no longer exists.
Search in your theme
The built-in theme already has a search box. A theme that replaces base.html adds the box wherever it should go:
{{template "search" .}}
The search partial renders nothing when search is off. When it’s on, it renders a form.site-search with an input and a .site-search-results container, plus the engine’s search.js, which does the fetching, ranking and keyboard handling. The form starts hidden and the script reveals it, so visitors without JavaScript never see a box that doesn’t work.
To change the markup, override the partial. Theme files are layered, so templates/partials/search.html in your theme replaces the built-in one, and {{asset "search.js"}} still finds the engine’s script. Keep the class names above and search.js keeps working. This site’s theme does exactly that to add an icon and a / hint:
{{define "search"}}{{with searchIndex}}
<form class="site-search" role="search" data-index="{{.}}" hidden>
<svg class="site-search-icon" …></svg>
<input type="search" name="q" placeholder="Search" aria-label="Search" autocomplete="off">
<kbd class="site-search-key" aria-hidden="true">/</kbd>
<div class="site-search-results" aria-live="polite" hidden></div>
</form>
<script src="{{asset "search.js"}}" defer></script>
{{end}}{{end}}
For a completely different UI, use the searchIndex template function. It returns the index URL, or "" when search is off, so it doubles as an on/off check:
{{if searchIndex}}<a href="#search">Search</a>{{end}}
Always get the URL from searchIndex rather than hard-coding /search.json.
Keyboard
| Key | In the search box |
|---|---|
| ↓ ↑ | Move through results |
| Enter | Open the first result, or the highlighted one |
| Esc | Close the results |
The / shortcut isn’t built in. This site’s theme adds it in a few lines of its own site.js.