Topic

Adding Search

Add client-side search to your Saga site.

Overview

Since Saga generates static HTML, search needs to happen client-side. There are two main approaches:

Binary index — these tools have a CLI that indexes your built site and produces a compact binary index, keeping bandwidth low:

  • Pagefind — indexes your built HTML and provides a fast search UI with no server required.
  • tinysearch — Rust-compiled-to-WebAssembly search with a very small footprint (~50kB for a typical blog), though it only matches complete words.

JSON index — these are runtime JS libraries that search a JSON index you load in the browser. You need to build that JSON index yourself (for example by using a Saga writer), and it can grow large on content-heavy sites:

  • Lunr.js — a lightweight, fully client-side search library.
  • Fuse.js — a fuzzy-search library that works well for smaller sites.
  • MiniSearch — a tiny, zero-dependency JS library with fuzzy matching and auto-suggestions.

This guide walks through integrating Pagefind.

Install Pagefind

Add Pagefind to your project via npm or pnpm:

$ pnpm init
$ pnpm add pagefind

Run Pagefind after the build

Use afterWrite(_:) to run Pagefind after each build:

import Foundation

try await Saga(input: "content", output: "deploy")
  .register(
    folder: "articles",
    metadata: ArticleMetadata.self,
    readers: [.parsleyMarkdownReader],
    writers: [.itemWriter(swim(renderArticle))]
  )
  .afterWrite { _ in
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
    process.arguments = ["pnpm", "pagefind", "--site", "deploy"]
    try process.run()
    process.waitUntilExit()
  }
  .run()

The afterWrite hook runs after every build cycle, including rebuilds triggered by saga dev. Pagefind generates its index and UI files into deploy/pagefind/.

Create a search page

Use createPage(_:using:) to add a search page:

try await Saga(input: "content", output: "deploy")
  .register(/* ... */)
  .createPage("search/index.html", using: swim(renderSearch))
  .run()

The search template loads Pagefind’s UI and wires it up:

func renderSearch(context: PageRenderingContext) -> Node {
  html {
    head {
      script(src: "/pagefind/pagefind-modular-ui.js")
      script {
        Node.raw(""" window.addEventListener('DOMContentLoaded', () => { const q = new URLSearchParams(window.location.search).get("q"); const instance = new PagefindModularUI.Instance(); instance.add(new PagefindModularUI.Input({ inputElement: "#search" })); instance.add(new PagefindModularUI.ResultList({ containerElement: "#results" })); if (q) { document.getElementById("search").value = q; instance.triggerSearch(q); } }); """)
      }
    }
    body {
      h1 { "Search" }
      form(action: "/search/") {
        input(id: "search", name: "q", placeholder: "Search articles", type: "text")
      }
      div(id: "results")
    }
  }
}

Tip In a real project you’d use a shared base layout function. See Reusable HTML Layouts for how to set that up.

Check the source of loopwerk.io for a complete working search implementation, including controlling what Pagefind indexes.