Statac

Publish

Search

A new site has no search box: give it one with an indexing tool you choose, run after each build, or with a list of your posts that the reader’s browser searches.

Which way?

With an indexing tool Without a tool
What you add An after_build command, and a snippet that loads the tool’s script A page and a template listing your posts, and a snippet holding a short script
What a reader’s browser fetches The parts of the index a search needs The whole list, when the reader first types
Under statac serve Made once, after serve’s first build, then stale until serve is started again Kept up to date on every save
Suits Any site, and a large one most A small site

The list’s size decides it. Measured on three real sites, as sent to the reader:

Posts Each post’s whole text Each post cut to 500 characters
45 14 KB not measured
343 0.9 MB 118 KB
1,616 2.5 MB 369 KB

A post cut short is found by its title and opening, not by a phrase further in.

With an indexing tool

  1. Run the tool after each build. In statac.yaml:

    after_build: indexer --site "$STATAC_OUT" --output "$STATAC_OUT/search"
    after_build_makes: [search/]

    indexer stands for the tool you choose. It reads the built pages in STATAC_OUT and writes its index, script and stylesheet into search/. after_build_makes tells the link check that search/ will be there.

  2. Write the box as a snippet. In snippets/search.html:

    {% set box = page.search_box() %}
    {% if box.first %}
    <link rel="stylesheet" href="{{ url("/search/search.css") }}">
    <script src="{{ url("/search/search.js") }}"></script>
    {% endif %}
    <div id="{{ box.id }}"></div>

    search.css and search.js stand for the files the tool writes. page.search_box() gives the box’s id, statac-search, and says whether it is the page’s first, the one that loads the tool’s files. url() keeps each address right on a site under a path.

  3. Place one box on a page, with {{ search() }} in a layout or @search() in a page’s writing.

  4. Build the site:

    statac build

    The tool runs once the site is built with no errors.

Without a tool

Nothing runs after the build, and nothing outside your site is needed.

  1. Make a page that lists the posts. In content/search-list.md:

    ---
    permalink: /search.json
    layout: search.json
    ---

    A layout whose name ends .json builds the page as JSON.

  2. Write its template. In templates/search.json:

    [{% for entry in site.posts %}
    {"title": {{ headline(entry) }}, "url": {{ entry.url }}, "text": {{ entry.content | plain }}}{% if not loop.last %},{% endif %}{% endfor %}
    ]

    Statac writes each value as JSON, so the template puts no quotes round them. headline(entry) names a post that has no title: too.

  3. Write the box as a snippet. In snippets/search.html:

    <search>
      <label for="search-words">Search</label>
      <input id="search-words" type="search" autocomplete="off">
      <p id="search-count" role="status"></p>
      <ul id="search-found"></ul>
    </search>
    <script>
    (() => {
      const words = document.getElementById("search-words");
      const count = document.getElementById("search-count");
      const found = document.getElementById("search-found");
      let pages;
      words.addEventListener("input", async () => {
        pages ??= await (await fetch("{{ site.languages[page.language].url }}search.json")).json();
        const wanted = words.value.toLowerCase().split(/\s+/).filter(Boolean);
        const hits = pages.filter((page) => {
          const text = `${page.title} ${page.text}`.toLowerCase();
          return wanted.length > 0 && wanted.every((word) => text.includes(word));
        });
        found.replaceChildren(...hits.slice(0, 20).map((page) => {
          const item = document.createElement("li");
          const link = document.createElement("a");
          link.href = page.url;
          link.textContent = page.title;
          item.append(link);
          return item;
        }));
        count.textContent = wanted.length > 0 ? `${hits.length} found` : "";
      });
    })();
    </script>

    It shows the first 20 posts that hold every word typed, newest first, and counts them all. It fetches the list when a reader first types. A new site’s assets/site.css already styles the box.

  4. Place one box on a page, with @search() in a page’s writing or {{ search() }} in a layout. Its ids are written in the snippet, so a second box on the page would repeat them.

A larger site

To keep the list small, cut each post to its opening in templates/search.json:

"text": {{ entry.content | plain | truncate(500, "") }}

Past a few hundred posts, an indexing tool is the better choice.

A site in several languages

content/
  search-list.md
  fr/
    search-list.md

On a site in several languages, /search.json holds the main language’s posts alone, and Statac says nothing of the others. A copy of the page in each other language’s folder makes that language’s list: content/fr/search-list.md is published at /fr/search.json.

Keep a page out of the index

In a new site’s templates/404.html, which extends base, add:

{% block body %} data-search-ignore{% endblock %}

data-search-ignore stands for the tool’s own mark for a page to leave out, often an attribute on <body>. A template that extends base writes on <body> through this block.

A new site’s 404 page asks search engines to leave it out, but an indexing tool goes by its own mark: until the page carries it, a search can find it.

A list made without a tool holds only site.posts, so pages that aren’t posts are never in it.

Where next?

  • Commands of your own: what an after_build command is handed, and when it runs
  • Snippets: calling a snippet from the writing and from a template
  • Templates: headline, url, and the plain and truncate filters
  • Addresses: a site under a path, and url()

Something unclear or wrong? Open an issue on GitHub.