Adding Search to an Eleventy Site Without Client-side JavaScript

Earlier this year (2023) I added a search feature to my blog. I’ve always appreciated being able to search other people’s personal sites directly without resorting to a third-party search engine. As I try to get back into writing more here, especially little web development notes to my future self, it will be nice to be able to find past ideas easier. I’ve also been wanting to explore “serverless functions” (aka, Someone Else’s Backend Server) as a way to expand the capabilities of a static website with no backend.

Intro

This iteration of my site is built with Eleventy, a static site generator. That means the HTML is not being built on-the-fly for each request, but built in advance, prior to deployment. Serverless functions are just a bit of code (in this case, JavaScript) that run in response to an HTTP request. Eleventy—or 11ty, as it is often styled—comes pre-bundled with a plugin for working with serverless functions.

My search feature looks through the blog posts for the search terms and weights the results depending on where the terms were found—the title, excerpt, or main content—in descending order of priority. It then returns an 11ty generated HTML page to the browser. There is no client-side JavaScript required because this is a regular HTML form posting to the function URL.

Let me show you how to do this.

JSON Data

First up, gather up all the blog posts. I did this with an 11ty JavaScript template which walks through each item in my blog collection and creates a JavaScript object containing the post URL, title, date, excerpt, and content. The output of this template is a big ol’ JSON array of these objects. This is saved by setting the permalink option to ./_generated-serverless-collections.json and permalinkBypassOutputDir to true. The latter option means the JSON file won’t be moved to the output directory, but just where the permalink specifies. I do this because the file is for the serverless function, not the deployed site.

// _generated-serverless-collections.11ty.js

exports.data = function () {
  return {
    permalink: "./_generated-serverless-collections.json",
    permalinkBypassOutputDir: true,
    layout: false,
  };
};

exports.render = function (data) {
  const entries = [];
  for (const entry of data.collections.blog) {
    const o = {
      url: entry.data.page.url,
      title: entry.data.title,
      date: entry.data.page.date,
      excerpt: entry.data.excerpt || "",
      content: entry.template.frontMatter.content,
    };
    entries.push(o);
  }

  return JSON.stringify({
    blog: entries, // Using a key allows future extensions like "site-pages"
  });
};

Serverless Function

Before writing the actual function, the 11ty’s serverless plugin needs to be configured. You can get the full details in the documentation, but briefly, I’ve added the plugin to my .eleventy.js config file with options that give it a name, an output directory, and a list of files and folders to copy into that function output directory. Importantly, this includes copying the JSON collection of blog posts.

When 11ty builds, the plugin generates all the files needed including a boilerplate index.js as the main function file. This is what runs when the function is invoked by the HTTP request. It has just a few steps:

  1. Get an instance of Eleventy in serverless mode.
  2. Build the page that’s been requested.
  3. Return the HTML content from that page.
    1. or if something goes wrong in steps 1–3, return an error.

All the magic happens in setting up the serverless Eleventy instance. Unfortunately, the options object for EleventyServerless is not clearly documented, but the Collections in Serverless section under Advanced describes how to pass precompiled collections using precompiledCollections (it can be found inside the collapsed code sample). Here’s what my code looks like for step 1:

// netlify/functions/search/index.js - snippet

const precompiledCollections = require("./_generated-serverless-collections.json");

const elev = new EleventyServerless("search", {
  path: new URL(event.rawUrl).pathname,
  query: event.queryStringParameters,
  functionsDir: "./netlify/functions/",
  precompiledCollections,
});

const [page] = await elev.getOutput();

The query parameter is what allows me to accept a search term from the user.

The rest of the steps are pretty much the same as the initial boilerplate.

Search Template

Eleventy Serverless builds an HTML page with search results and returns it to the browser. To manage complex search term matching, this template is written in JavaScript. I’m using a class with a render function, which is one of the styles Eleventy recognises for .11ty.js template files.

To start, I get the search query from the Eleventy Serverless data object and prepare the search terms. Since I’m using regular expressions I escape special characters using MDN’s method. I then split up each word, unless they’re quoted, and ignore terms less than 3 characters. This gives me an array of individual search terms to which I also add the original full phrase.

// search.11ty.js - snippet

const searchQuery = data.eleventy.serverless.query.s || "";
const query = escapeRegExp(searchQuery.trim()); // See link to MDN's docs

// Split on spaces unless in double quotes -- https://regex101.com/r/ztR87p/1
// Then strip double quotes and terms less than 3 characters
const terms = query
  .split(/ (?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/)
  .map(term => term.replace(/"/g, ""))
	.filter(term => term.length >= 3);

...
if (terms.length > 1) {
  terms.push(query); // If there's just 1 item, it's the original search query
}
...

Next, loop through each blog post and check for each term in the title, excerpt, and main content. This is where I weight matching blog posts for relevancy. The weight is determined by where the term was matched: 3 for the title, 2 for the excerpt, and 1 for the main content. These are summed for each term and then all terms are summed for the total weight of the post. Currently, I only test for the presence of the term in each location, not the number of times it is found (i.e., the search term can be in the content 5 times, but that only adds 1 to the weight).

// search.11ty.js - snippet

data.collections.blog.forEach((post) => {
  const { title, url, date, excerpt, content } = post;
  const weight = 0;

  terms.forEach((term) => {
    const regExpBoundedTerm = new RegExp(`(^|\\W)${term}($|\\W)`, "i");

    weight += regExpBoundedTerm.test(title) && 3;
    weight += regExpBoundedTerm.test(excerpt) && 2;
    weight +=
      regExpBoundedTerm.test(content.replace(/\[.*?]\(.*?\)/g, "")) && 1;
  });

  if (weight) {
    results.push({ weight, url, title, date });
  }
});

For each matching blog, an object containing the title, url, publish date, and weight is pushed onto a results array. The entire result set is then sorted by weight, with ties broken by published date. I have some code in the file to sort just by date, and to reverse the sorting direction as well, but haven’t revealed that functionality in the interface at this point.

// search.11ty.js - snippet

switch (sortBy) {
  case "date":
    results.sort((a, b) => {
      const aDate = new Date(a.date);
      const bDate = new Date(b.date);
      if (aDate === bDate) {
        return b.weight - a.weight;
      }
      return bDate - aDate;
    });
    break;
  default:
    results.sort((a, b) => {
      if (a.weight === b.weight) {
        return new Date(b.date) - new Date(a.date);
      }
      return b.weight - a.weight;
    });
}
if (sort === "asc") {
  results.reverse();
}

From there, It’s just a matter of displaying a list of results to the reader. For this I create an HTML string with template literals, looping through each result to build a list of linked titles, plus a form for new searches. A few if statements manage HTML for when there are no results or an error happened. The final string is then returned from the render function for 11ty.

// search.11ty.js - snippet

let html = `<ul>
${results
  .map(
    (result) =>
      `<li><a href="${result.url}">${
        result.title
      }</a> - <time>${this.formatDate(result.date)}</time></li>`
  )
  .join("\n")}
</ul>`;

return html;

Bonus: If you display the original search term on this page, don’t forget to escape special characters (not that there’s any user information to steal on my site).

function encodeHTML(string) {
  return string.replace(/[<>]/g, (c) => "&#" + c.charCodeAt(0) + ";");
}

Further Enhancements and Conclusion

It wouldn’t take too much to provide the JSON file of blog content to the static site and use JavaScript to enhance the form and search live directly in the browser. The results page could update as you type and make that feedback loop even quicker. I haven’t done that yet and to be honest, being a plain HTML site, it’s already very fast and I don’t see the need. Maybe in the future as my blog archive grows, I’ll need to find ways to improve the performance. For now, I’m happy with it.

Thanks for reading through all this. It was definitely beneficial for me to do a full write-up on how I did things. The code was tidied as a result and I have a better understanding of how all the different parts tie together. Hopefully, you found it helpful as well!