My Website

Search

    FlexSearch in this project

    This site uses FlexSearch's Document index to provide client-side, full-text search across all blog posts, with no server and no build-time bundler required. Everything runs as a native ES module in the browser.

    There are three moving parts:

    1. .eleventy.js — ships the FlexSearch browser bundle as a static asset.
    2. src/search-index.njk — generates a JSON search index from collections.posts at build time.
    3. src/_includes/header.njk — loads the index into a FlexSearch Document in the browser and renders a search modal (a native <dialog>) wired up to it.

    Because header.njk is included by base.njk, this modal and its script are present on every page.

    1. Shipping FlexSearch as a static asset

    // .eleventy.js
    eleventyConfig.addPassthroughCopy({
        "node_modules/flexsearch/dist/flexsearch.bundle.module.min.mjs": "assets/flexsearch.bundle.module.min.js",
    });
    

    flexsearch is an npm dependency (package.json), but it's never imported into any Node build step here. Instead, Eleventy just copies FlexSearch's pre-built ESM bundle straight into _site/assets/ so the browser can import it directly:

    <script type="module">
        import { Document } from "/assets/flexsearch.bundle.module.min.js";
    </script>
    

    The file is renamed from .mjs to .js on copy only so static file servers reliably serve it with a JavaScript MIME type — the contents (an ES module with export statements) are unchanged.

    This bundle includes FlexSearch's Index, Document, Charset, Encoder, Worker, Resolver, and IndexedDB exports. This project only uses Document.

    2. Building the search index (src/search-index.njk)

    ---
    permalink: /search-index.json
    eleventyExcludeFromCollections: true
    ---
    [
    {%- for post in collections.posts -%}
        {%- if not loop.first -%},{%- endif -%}
        {
            "id": {{ loop.index0 | dump | safe }},
            "title": {{ post.data.title | dump | safe }},
            "url": {{ post.url | dump | safe }},
            "tags": {{ post.data.tags | dump | safe }},
            "content": {{ post.templateContent | striptags | trim | dump | safe }}
        }
    {%- endfor -%}
    ]
    

    This is a Nunjucks template whose output is JSON, not HTML — Eleventy writes it to /search-index.json in the built site. A few things worth calling out:

    • eleventyExcludeFromCollections: true keeps this generated file out of collections.all / collections.posts, so it doesn't accidentally show up as a "post" anywhere.
    • post.templateContent is the post's fully rendered HTML (Eleventy computes this for every collection item — it's the same mechanism RSS feed templates use).
    • striptags (a built-in Nunjucks filter) strips the HTML tags from that rendered content, leaving plain text suitable for indexing.
    • dump | safe is doing the JSON-escaping. Nunjucks' dump filter is JSON.stringify under the hood, so {{ post.data.title | dump | safe }} renders a correctly quoted-and-escaped JSON string (or array, for tags) directly into the output — this is what makes it safe to hand-roll JSON with string concatenation in a template instead of needing a real JSON serializer.

    The result looks like:

    [
      {
        "id": 0,
        "title": "Using Svelte with Eleventy",
        "url": "/posts/using-svelte-with-11ty/",
        "tags": ["posts", "svelte", "11ty"],
        "content": "Combine Eleventy's speed with Svelte 5's modern reactivity by precompiling..."
      }
    ]
    

    This file is rebuilt on every Eleventy build, so it's always in sync with src/posts/*.md.

    3. Building the index in the browser (src/_includes/header.njk)

    3.1 Configuring the Document index

    const index = new Document({
        document: {
            id: "id",
            store: ["title", "url"],
            index: ["title", "content", "tags"],
        },
        tokenize: "forward",
    });
    

    FlexSearch has two index types: Index (search over a single flat string per entry) and Document (search over multiple named fields of structured objects, this project's case).

    • document.id — which field of each added object is its unique identifier.
    • document.index — which fields get tokenized and searched (title, content, tags). Each field gets its own internal index, so a query can match on title text, body text, or tag names independently.
    • document.store — which fields are kept, verbatim, alongside the index so they can be retrieved in results without a second lookup (title and url, everything the results list needs to render a link). content is deliberately not stored — it's only there to be searchable, not to be displayed, so storing it would just waste memory.
    • tokenize: "forward" controls how strings are split into searchable tokens. "forward" indexes every forward-growing substring of each word ("search"s, se, sea, sear, searc, search), which enables prefix matching — so typing "sve" matches "svelte" as the user is still typing it, which is what you want for an as-you-type search box like this one.

    3.2 Populating the index

    const posts = await fetch("/search-index.json").then((res) => res.json());
    
    for (const post of posts) {
        index.add({
            id: post.id,
            title: post.title,
            content: post.content,
            tags: (post.tags || []).join(" "),
            url: post.url,
        });
    }
    

    On every page load, the browser fetches the generated /search-index.json and adds every post to a fresh, in-memory FlexSearch index. There's no persistence (no IndexedDB, no caching) — the index is rebuilt from scratch each time the page loads. For a handful of posts this is effectively instant; if the post count grows into the thousands, this is the first place to revisit (see Scaling, below).

    Note tags is joined into a single space-separated string before being added — FlexSearch indexes each field as text, so an array needs to be flattened to a string first.

    3.3 Querying the index

    input.addEventListener("input", () => {
        const query = input.value.trim();
        results.innerHTML = "";
        if (!query) return;
    
        const matchesById = new Map();
        for (const fieldResult of index.search(query, { enrich: true, limit: 10 })) {
            for (const match of fieldResult.result) {
                matchesById.set(match.id, match.doc);
            }
        }
    
        for (const doc of matchesById.values()) {
            const li = document.createElement("li");
            const a = document.createElement("a");
            a.href = doc.url;
            a.textContent = doc.title;
            li.appendChild(a);
            results.appendChild(li);
        }
    });
    

    This runs on every keystroke in the search input (no debounce — the index is small and in-memory, so this is cheap).

    • index.search(query, { enrich: true, limit: 10 }) searches all indexed fields (title, content, tags) and returns one result set per field, e.g.:

      [
        { field: "title",   result: [{ id: 3, doc: {...} }] },
        { field: "content", result: [{ id: 3, doc: {...} }, { id: 1, doc: {...} }] },
        { field: "tags",    result: [] },
      ]
      

      Without enrich: true, each result entry would just be an id; enrich: true tells FlexSearch to look up the stored fields (title, url) and attach them as .doc on each match.

    • The Map keyed by match.id flattens those per-field result sets into a single deduplicated list — a post that matches on both its title and its content should only appear once in the results.

    • limit: 10 caps each field's result count at 10 (not the combined total) — with only a few posts in this site that ceiling is never reached, but it's worth knowing it isn't a global cap on the merged list.

    4. The search modal

    The search UI is a native <dialog> element (styled by Pico CSS's built-in modal pattern), not a separate page:

    <button type="button" id="search-open">Search</button>
    
    <dialog id="search-dialog">
        <article>
            <header>
                <button aria-label="Close" rel="prev" id="search-close"></button>
                <p>Search</p>
            </header>
            <input type="search" id="search-input" placeholder="Search posts...">
            <ul id="search-results"></ul>
        </article>
    </dialog>
    
    openButton.addEventListener("click", () => {
        dialog.showModal();
        input.focus();
    });
    
    closeButton.addEventListener("click", () => dialog.close());
    
    dialog.addEventListener("click", (event) => {
        if (event.target === dialog) dialog.close();
    });
    
    dialog.addEventListener("close", clearSearch);
    
    • dialog.showModal() opens it as a true modal (focus-trapped, with a backdrop, and — for free — closes on Escape without any code of ours).
    • Backdrop click to close works by checking event.target === dialog: clicks inside the <article> don't bubble up with the dialog itself as the target, only clicks in the dialog's own padding/backdrop area do.
    • Every dismissal path — the X button, backdrop click, or Escape — ends by firing the dialog's native close event, which is the single place clearSearch() resets the input and results. This means there's one code path for "the modal just closed," not three.

    Maintenance notes

    • Adding/editing posts: nothing to do — search-index.njk regenerates from collections.posts on every build.
    • Indexing another field (e.g. description): add it to document.index in the Document config, add it to the object passed to index.add(...), and add it to the JSON emitted by search-index.njk.
    • Scaling beyond a small blog: if the post count or content size grows significantly, consider:
      • Building the FlexSearch index once at build time and serializing it (index.export(...)) instead of re-indexing raw JSON on every page load.
      • Debouncing the input listener.
      • Using FlexSearch's Worker export to move indexing/search off the main thread.