Search Index Generator
The search index generator (scripts/generate_search_index.ts) produces per-locale JSON index files from the rendered dist/ HTML. Unlike a third-party search service, the index is built from exactly what the static site rendered — no separate crawl, no external dependency, no ongoing cost. The client-side search widget (src/public/js/site-search.js) fetches the index on first use of the Cmd+K dialog and performs fuzzy matching locally.
bun ssg:search
This runs as the last step in bun ssg. It can also be run standalone to rebuild the index without re-rendering the whole site.
CLI Options
bun scripts/generate_search_index.ts [options]
| Option | Default | Description |
|---|---|---|
--public <dir> | ./src/public | Source directory with page templates |
--dist <dir> | ./dist | Rendered site directory; indexes are written under it |
--help | - | Print usage and exit |
Output
For each configured source and each active locale, the generator writes one JSON file:
dist/search-index.json ← default locale, whole-site source
dist/en-us/search-index.json ← English locale
dist/sl-si/search-index.json ← Slovenian locale
Multiple sources (see Configuration) produce multiple files per locale:
dist/search-index.json ← default locale, "" source (whole site)
dist/docs/search-index.json ← default locale, "/docs" source
dist/en-us/docs/search-index.json
Index Format
Each index is a flat JSON object:
{
"site": "/docs",
"records": [
{
"url": "/docs/install",
"anchor": "prerequisites",
"title": "Installation",
"heading": "Prerequisites",
"text": "ReeWeb requires Bun 1.2 or later. Install it from..."
}
]
}
| Field | Description |
|---|---|
site | URL prefix this index covers (matches the source prefix). Used for result grouping. |
url | Canonical page URL, e.g. "/docs/translations". |
anchor | Heading id for deep-linking to the matching section. Empty for lead content. |
title | Page title (every record from the same page shares this). |
heading | Section heading text. Equals title for the lead section before the first heading. |
text | Plain-text section body, capped at 1500 characters for compactness. |
Each rendered page is split into one record per h1–h3 heading, so a search match deep-links directly to the relevant section rather than just the page top.
Configuration
Search behaviour is configured in config/search.ts:
import type { SearchConfig } from "$config/search";
export const search: SearchConfig = {
enabled: true,
sources: [{ prefix: "", brand: "" }],
strip: [],
};
| Field | Type | Description |
|---|---|---|
enabled | boolean | When false, bun ssg:search exits without writing anything. |
sources | SearchSource[] | One emitted index per entry. The default indexes the whole site into dist/search-index.json. |
strip | string[] | Exact strings removed from indexed text — repeated page chrome (banners, footers) that would otherwise match every query. |
SearchSource
| Field | Type | Description |
|---|---|---|
prefix | string | URL prefix for this index. "" indexes the entire site. /docs indexes only pages under /docs. |
brand | string | Group heading above this source's results in the search dialog. "" renders no heading. |
root | string | Source folder under src/public/. Defaults to prefix without its leading slash. |
Multi-Source Example
Split documentation from blog so the client fetches the active section's index first and the rest in the background:
export const search: SearchConfig = {
enabled: true,
sources: [
{ prefix: "", brand: "" },
{ prefix: "/docs", brand: "Documentation" },
{ prefix: "/blog", brand: "Blog" },
],
strip: ["★ New: Reepolee Studio is now available"],
};
The client-side search widget fetches the current section's index on first keystroke, then lazy-loads remaining indexes in the background so the full site is searchable as the visitor continues typing.
Visibility Rules
The generator honours the same visibility policy as the sitemap. Pages are excluded from the index when:
- The page is a draft (
draft: truefrontmatter) - The page has a future
published_atdate - The page has
noindex: trueorsitemap: falsefrontmatter
Pages excluded from search are still rendered and served — they just don't appear in results.
How It Works
- Loads translations via
load_all_translations()to build the route map for localized URLs. - Collects page files via
collect_page_files(), excluding drafts. - Builds the route map for per-locale URL resolution (mirrors the sitemap).
- For each (locale × source) pair, filters pages belonging to that source.
- Reads each rendered HTML page from
dist/— the index reflects exactly what was built, not the raw templates. - Extracts the article body — prefers
<article class="article-body">(markdown pages), falls back to<main>. - Splits at headings — each
h1–h3with anidbecomes a section record with its ownanchorandheading. - Strips repeated chrome — configurable via
stripinconfig/search.ts. - Writes the JSON index to
dist/<locale-prefix><source-prefix>/search-index.json.
The dev server (scripts/dev/static_files.ts) serves previously-built indexes, so search works locally after a bun ssg run — no separate dev-time index rebuild.
Section Splitting
Content before the first heading becomes the lead section (anchor "", heading = page title). Each subsequent h1–h3 with an id attribute opens a new section. h4–h6 text stays inside the enclosing section.
Text extraction strips all HTML tags, decodes entities, collapses whitespace, removes <script> and <style> blocks, and applies the strip list.
Client Integration
The generated indexes are consumed by the client-side search widget, which:
- Hooks the Cmd+K / Ctrl+K keyboard shortcut.
- Fetches the index for the current URL prefix on first keystroke.
- Lazy-loads remaining indexes in the background.
- Performs fuzzy matching locally against the preloaded records.
- Renders results grouped by
brand, with each result deep-linking to its sectionanchor.
The widget lives at src/public/js/site-search.js and requires no build step beyond the index generation itself.