Examples
EN

Search

Hwaro generates a search index that works with Fuse.js for client-side search.

Configuration

Enable in config.toml:

[search]
enabled = true
format = "fuse_json"
fields = ["title", "content", "description", "tags", "url", "section"]
filename = "search.json"
exclude = ["/private", "/drafts"]
Key Type Default Description
enabled bool false Generate search index
format string "fuse_json" Search index format: fuse_json, fuse_javascript, elasticlunr_json, or elasticlunr_javascript (the *_javascript forms wrap the array in a var searchData = ... script)
fields array ["title", "content"] Fields to include in index — by default only title and content (url and lang are always added)
filename string "search.json" Output filename
exclude array [] Paths (prefixes) to exclude from search index
tokenize_cjk bool false Enable CJK bigram tokenization
shards string "none" Split the index into lazy-loadable shards: "section", "language", or "section-language" — see Sharded index
single_file bool true With shards on, keep emitting the classic search.json alongside the shards; false emits shards only
content_max_length int 0 When > 0, truncate each entry's content to that many characters at a word boundary; 0 keeps the full text
split_by_heading bool false Also emit one record per h2/h3 section that deep-links to #heading — see Heading-level records
facets array [] Filterable fields added to every record: "section", "lang", "tags" or any taxonomy name — see Facets
ui bool false Publish the built-in search UI and fill {{ search_tags }} — see Built-in UI. Needs a JSON format

Generated Files

When enabled, Hwaro generates /search.json (configurable via filename):

[
  {
    "title": "My Post",
    "url": "/blog/my-post/",
    "content": "Page content...",
    "description": "Post description",
    "section": "blog",
    "tags": ["tutorial"],
    "lang": "en"
  }
]

Fields Indexed

Only fields listed in fields are emitted (url and lang are always included, and pages of a versioned site also carry version):

Field Description
title Page title
url Page URL
content Page content (if "content" is in fields)
description Page description
section Section name
tags Page tags
lang Page language (always included)

Built-in UI

Hwaro ships a small search UI, so a site does not have to write its own client. It has no dependencies and makes no requests to other hosts.

[search]
enabled = true
format = "fuse_json"        # or "elasticlunr_json"; a *_javascript format is a config error
ui = true
split_by_heading = true     # optional: results deep-link to sections
facets = ["section", "tags"] # optional: filter chips

Put {{ search_tags }} in your <head>, and add a button that opens the search:

<head>
  {{ search_tags }}
</head>
<body>
  <button type="button" data-hwaro-search>Search</button>
  ...
</body>

With ui = true, the build writes assets/hwaro-search/search.js and assets/hwaro-search/search.css. search_tags holds a <link> and a <script defer> tag for them:

search_tags is an empty string while ui is off, so a template can include it unconditionally.

Behaviour

Styling

The stylesheet uses CSS custom properties and follows prefers-color-scheme (and data-theme="dark"/"light" on <html>). Override them in your own CSS:

.hwaro-search-overlay, .hwaro-search-inline {
  --hwaro-search-accent: #c2410c;
  --hwaro-search-radius: 4px;
  --hwaro-search-font: "Inter", sans-serif;
}

The other properties are --hwaro-search-bg, -fg, -muted, -border, -active, -mark and -backdrop.

The build always writes its own assets/hwaro-search/ files. A file at the same path under static/ is replaced, and the build warns about it.

Translations

The UI text comes from i18n/<lang>.toml. English is the built-in default for every key:

# i18n/ko.toml
[search]
placeholder = "검색"
no_results = "결과 없음"
results_count = "{count}개 결과"   # {count} is replaced by the number
close = "닫기"

For different singular and plural forms, use a table instead (the same one/other rule as the pluralize filter):

[search.results_count]
one = "{count} result"
other = "{count} results"

A missing key falls back to the default language, then to English.

Heading-Level Records

With split_by_heading = true, every page also gets one record per h2/h3 section, so a result can land on the right part of a long page:

{"title": "Install", "content": "Run the installer...", "url": "/docs/install/", "lang": "en"},
{"title": "Install", "content": "Download the binary...", "url": "/docs/install/#macos", "lang": "en", "heading": "macOS"}

Facets

facets adds filterable fields to every record (page and section records):

[search]
facets = ["section", "lang", "tags", "category"]
Facet Value
section The page's section path (blog/news)
lang The page language (already on every record)
tags The page's tags
any taxonomy name That taxonomy's terms for the page (an empty list when it has none)

An unknown name is ignored with a warning that lists the valid names. The built-in UI turns the facets into filter chips; a custom client can filter on the fields directly.

Client-Side Implementation

Using Fuse.js

Add to your template:

<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
<script>
let searchIndex = [];

// Load index
fetch('/search.json')
  .then(res => res.json())
  .then(data => {
    searchIndex = data;
  });

// Initialize Fuse.js
function search(query) {
  const fuse = new Fuse(searchIndex, {
    keys: ['title', 'content', 'description', 'tags'],
    threshold: 0.3
  });
  return fuse.search(query);
}
</script>

Search Form

<form id="search-form">
  <input type="search" id="search-input" placeholder="Search...">
</form>

<div id="search-results"></div>

<script>
const input = document.getElementById('search-input');
const results = document.getElementById('search-results');

input.addEventListener('input', (e) => {
  const query = e.target.value;
  if (query.length < 2) {
    results.innerHTML = '';
    return;
  }
  
  const matches = search(query);
  results.innerHTML = matches
    .slice(0, 10)
    .map(m => `
      <a href="${m.item.url}">
        <h3>${m.item.title}</h3>
        <p>${m.item.description || ''}</p>
      </a>
    `)
    .join('');
});
</script>

CJK Search Support

For sites with Chinese, Japanese, or Korean content, enable CJK tokenization to improve search accuracy. CJK languages often lack spaces between words, making it difficult for search libraries to tokenize text properly.

When enabled, CJK character runs are split into overlapping bigrams (2-character pairs), allowing search terms to match within longer text.

Example: "검색엔진" → "검색 색엔 엔진" (search query "검색" now matches)

Configuration

[search]
enabled = true
tokenize_cjk = true
Key Type Default Description
tokenize_cjk bool false Enable CJK bigram tokenization for search index

How It Works

Notes

Excluding Pages

Front Matter

Exclude individual pages from search with front matter:

+++
title = "Terms of Service"
in_search_index = false
+++

Configuration

Exclude entire sections or paths using config.toml:

[search]
exclude = ["/private", "/drafts"]

Field Selection

Control which fields appear in the search index by specifying fields:

[search]
enabled = true
fields = ["title", "description", "tags", "url"]

Available fields: title, content, description, tags, url, section.

Omitting content from fields significantly reduces the index file size for large sites.

Performance Tips

Large Sites

For sites with many pages:

  1. Remove "content" from fields to reduce index size
  2. Use Fuse.js ignoreLocation option
  3. Implement debounced search
function debounce(fn, delay) {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn(...args), delay);
  };
}

input.addEventListener('input', debounce((e) => {
  // search logic
}, 200));

Lazy Loading

Load index only when search is focused:

let indexLoaded = false;

input.addEventListener('focus', async () => {
  if (indexLoaded) return;
  const res = await fetch('/search.json');
  searchIndex = await res.json();
  indexLoaded = true;
});

Sharded Index

A single search.json grows with the site, and every visitor pays for the whole file before the first search. Sharding splits the index into several JSON files plus a manifest so a client can load only what it needs: the current section first, or one language at a time.

[search]
enabled = true
shards = "section"          # "section" | "language" | "section-language"
single_file = false         # emit shards only (default true keeps search.json too)
content_max_length = 500    # optional: cap each entry's content
Mode Shards Example ids
"section" One per top-level content section; pages outside any section go to _root blog, docs, _root
"language" One per language (multilingual sites); the default language uses its code en, ko
"section-language" Language, then section en/blog, ko/blog, ko/_root

Nested sections fold into their top-level section: blog/news/post.md lands in the blog shard. Every eligibility rule of the classic index applies unchanged (fields, exclude, in_search_index = false, drafts, render = false, per-language build_search_index, tokenize_cjk).

Generated Files

public/
├── search.json          # unless single_file = false
└── search/
    ├── index.json       # manifest
    ├── _root.json
    ├── blog.json
    └── docs.json        # nested ids use directories: search/ko/blog.json

Each shard is a plain JSON array with exactly the same entry schema as search.json (shards are always JSON, whatever format says; the *_javascript wrapper only serves <script src> loading). search/index.json describes the layout:

{
  "version": 1,
  "fields": ["title", "content", "url", "lang"],
  "shards": [
    {"id": "_root", "url": "/search/_root.json", "language": null, "section": "", "count": 2, "bytes": 200},
    {"id": "blog",  "url": "/search/blog.json",  "language": null, "section": "blog", "count": 42, "bytes": 12345}
  ]
}

Lazy-Loading Shards with Fuse.js

Fetch the manifest once, then load shards on demand. A global search box loads all of them; a section-aware one loads the current section's shard first and the rest in the background:

<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
<script>
const loaded = new Map();       // shard id → entries
let manifest = null;
let fuse = null;

async function loadManifest() {
  if (manifest) return manifest;
  manifest = await (await fetch('/search/index.json')).json();
  return manifest;
}

async function loadShard(shard) {
  if (loaded.has(shard.id)) return;
  loaded.set(shard.id, await (await fetch(shard.url)).json());
  fuse = new Fuse([...loaded.values()].flat(), {
    keys: ['title', 'content', 'description', 'tags'],
    threshold: 0.3,
    ignoreLocation: true
  });
}

// Which shards matter for this page? Match the manifest against the
// document language and the first URL segment; fall back to everything.
async function loadRelevantShards() {
  const { shards } = await loadManifest();
  const lang = (document.documentElement.lang || '').split('-')[0];
  const section = location.pathname.split('/').filter(Boolean)[0] || '';
  const local = shards.filter(s =>
    (s.language === null || s.language === lang) &&
    (s.section === null || s.section === section));
  await Promise.all((local.length ? local : shards).map(loadShard));
  // Warm the remaining shards without blocking the first results.
  shards.filter(s => !loaded.has(s.id)).forEach(s => loadShard(s));
}

function search(query) {
  return fuse ? fuse.search(query) : [];
}

document.getElementById('search-input').addEventListener('focus', loadRelevantShards, { once: true });
</script>

For a purely global box replace loadRelevantShards with shards.map(loadShard). Everything after loading is the same Fuse.js code as the single-file setup above.

Alternative: Pagefind

For larger sites, consider Pagefind:

# After build
npx pagefind --site public

Add to config as post-build hook:

[build]
hooks.post = ["npx pagefind --site public"]

See Also