Cory Dransfeldt

Datexmlns="http://www.w3.org/2000/svg"width="24"height="24"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"class="icon icon-tabler icons-tabler-outline icon-tabler-calendar-month">

Lightweight search in Eleventy

I've been using Pagefind for my site search for a while now and would readily recommend it, but I wanted to throw together something a bit lighter weight and customizable.

Enter minisearch: a tiny full-text search library written in JavaScript. To populate it, I needed to generate an index (which Pagefind was previously handling for me):

export const searchIndex = (collection) => {
  const searchIndex = []
  let id = 0
  const collectionData = collection.getAll()[0]
  const posts = collectionData.data.collections.posts
  const links = collectionData.data.links
  if (posts) {
    posts.forEach((post) => {
      const url = post.url.includes('http') ? post.url : `https://coryd.dev${post.url}`
      searchIndex.push({
        id,
        url,
        title: `📝: ${post.data.title}`,
        text: post.data.description,
        tags: post.data.tags.filter((tag) => tag !== 'posts'),
      })
      id++;
    })
  }
  if (links) {
    links.forEach((link) => {
      searchIndex.push({
        id,
        url: link.url,
        title: `🔗: ${link.title}`,
        text: link.summary,
        tags: link.tags,
      })
      id++;
    })
  }
  return searchIndex
}

I've created a custom collection, above, that adds both post and link data (from my links page) to a searchIndex array using a uniform object shape for each and the same emoji I use to distinguish the content types when POSSE-ing them out from my site. This allows both my posts and shared links to be searched which, previously, was not the case.

Next, this is exposed as JSON at /api/search via a liquid template:

---
layout: null
eleventyExcludeFromCollections: true
permalink: /api/search
---
{{ collections.searchIndex | json }}

Next, we need to copy the minisearch library JavaScript into our site output in .eleventy.js:

eleventyConfig.addPassthroughCopy({
  'node_modules/minisearch/dist/umd/index.js': 'assets/scripts/components/minisearch.js',
})

From there, we write some basic event handling to leverage the library and pass in user input:

(() => {
  const miniSearch = new MiniSearch({
    fields: ['title', 'text', 'tags']
  })

  const $form = document.querySelector('.search__form')
  const $input = document.querySelector('.search__form--input')
  const $fallback = document.querySelector('.search__form--fallback')
  const $results = document.querySelector('.search__results')

  // remove noscript fallbacks
  $form.removeAttribute('action')
  $form.removeAttribute('method')
  $fallback.remove()

  let resultsById = {}

  // fetch index
  const results = fetch('/api/search').then(response => response.json())
  .then((results) => {
    resultsById = results.reduce((byId, result) => {
      byId[result.id] = result
      return byId
    }, {})
    return miniSearch.addAll(results)
  })

  $input.addEventListener('input', (event) => {
    const query = $input.value
    const results = (query.length > 1) ? getSearchResults(query) : []
    if (query === '') renderSearchResults([])
    renderSearchResults(results)
  })

  const getSearchResults = (query) => miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } }).map(({ id }) => resultsById[id])
  const renderSearchResults = (results) => {
    $results.innerHTML = results.map(({ title, url }) => {
      return "`<li class="search__results--result"><a href="${url}">${title}</a></li>`"
    }).join('\n')

    if (results.length > 0) {
      $results.classList.remove('hidden')
    } else {
      $results.classList.add('hidden')
    }
  }
})();

And, finally, I've added this all to my search.html page:

<script src="/assets/scripts/components/minisearch.js"></script>
{% capture js %}
{% render "../assets/scripts/search.js" %}
{% endcapture %}
<script type="module">{{ js }}</script>
<form class="search__form" action="https://duckduckgo.com" method="get">
  <input class="search__form--input" placeholder="Search" type="search" name="q" autocomplete="off" autofocus>
  <input class="search__form--fallback" type="hidden" placeholder="Search" name="sites" value="coryd.dev">
</form>
<ul class="search__results hidden"></ul>

It should also be noted that this JavaScript amounts to an enhancement — if the user does have JavaScript enabled, this all runs as explained and removes the fallback field and form action and method attributes. If it doesn't, searches of my site will be surfaced using DuckDuckGo.

Discuss on Mastodon