Adding search capabilities to Ghost

 ⋅ 3 min read ⋅  Featured Post

Ghost is a relatively new blogging platform that I've been hacking away at lately, and what powers this blog. It started as a Kickstarter campaign by John O'Nolan (former deputy lead for the WordPress UI team) and got funded in 11 hours. It was publicly released in 2013, and has slowly been adding more features and polish.

I primarily chose Ghost because it is built on top of Node.js (my current favorite language), even though the platform is still lacking quite a number of features, search for example.

In this post, I'll show you how I added really fast browser-side Full-Text Search (FTS) capabilities to my Ghost installation.

Researching existing solutions

While the Ghost team is debating how to build this feature, I couldn't wait and decided to hack something together.

A quick Google search shows up a few solutions —

  1. Google Custom Search
  2. ghostHunter

I didn't like Google Custom Search because I wanted the search experience to be seamless (and, well, not look like 💩).

ghostHunter, while seeming like a compelling solution, depends on jQuery and Lunr.js, which together add roughly 120 KB to the page. In keeping with the whole philosophy behind my blog's UI, I wanted something really lightweight.

Say hello to Fuse.js

Fuse.js is a really small (10 KB) fuzzy search library with zero dependencies, and is a perfect fit for a small site like mine.

Using Fuse.js is pretty straightforward —

  1. Import the library.
  2. Fetch all your search-able data.
  3. Build the search index.
  4. Query the index.

When implementing it on this blog, I wanted to use Web Workers to make sure the heavy JavaScript stuff doesn't block the UI thread — I'd really recommend reading the linked articles to learn more about Web Workers.

Building a Web Worker for Fuse.js

On page load, a worker is initialized, which fetches all posts data from Ghost's API and uses Fuse.js to build an index. When the user submits a search, a message is posted to the worker to start a search. The worker receives this, searches on the now-built index, and posts back the results; this message is received by the main page, which finally displays the results.

The dirty details

In the Ghost theme's default.hbs template, this snippet is included inside the body's <script> block —

// Set up search options for the /posts API.
// You can learn more about the API here - https://api.ghost.org/docs/posts
var searchFilter = {
  limit: 'all',
  include: 'tags',
  formats:['plaintext'],
  fields: 'id,url,title,plaintext,description,tag,featured,published_at'
};
var searchEndpoint = ghost.url.api('posts',searchFilter); // `ghost` is globally available.

// Initialize Web Worker.
var indexer = new Worker('/assets/js/indexer.js');
// Set up message listener.
indexer.onmessage = function(event) {
    var searchResults = event.data;
    // Implement logic to display search results.
    // ...
}

// Post message to begin fetching posts data and build search index.
indexer.postMessage({searchEndpoint: searchEndpoint});

// On click of the search button, post message to search.
var search = function(searchString) {
    indexer.postMessage({searchString: searchString});
};

Next up is the Web Worker - indexer.js.

// Import the Fuse.js library.
// Note that `importScripts` is a native API available only in Web Workers. 
importScripts('/assets/js/fuse.min.js');
var fuse;

// Message listener.
self.onmessage = function(event) {
    var searchEndpoint = event.data.searchEndpoint;
    if (searchEndpoint) {
        // Initial request for fetching posts data & building search index.
        var request = new XMLHttpRequest();
        request.open('GET', searchEndpoint, true);
        request.onload = function() {
            if (request.status >= 200 && request.status < 400) {
                var postData = JSON.parse(request.responseText);
                var searchOptions = {
                    // This distribution is entirely customizable.
                    keys: [
                        {name: 'title', weight: 0.3},
                        {name: 'plaintext', weight: 0.2},
                        {name: 'description', weight: 0.2},
                        {name: 'link', weight: 0.1},
                        {name: 'tag', weight: 0.15},
                        {name: 'id', weight: 0.05},
                    ]
                };
                fuse = new Fuse(postData.posts, searchOptions);
            }
        };
        request.send();
    } else {
        // Search request.
        var searchString = event.data.searchString;
        var searchResults = fuse.search(searchString);
        self.postMessage(searchResults);
    }
}
Feature detection

Although most modern browsers support Web Workers, it is probably a good idea to detect support for them, so all of the code is wrapped in —

if (window.Worker) {
    // ...
}

Possible optimizations

If you've noticed, the entire Fuse.js index stays in memory — this doesn't scale well as the blog grows bigger. To better cope with growing content, the index can be cached in IndexedDB with a way to bust it (perhaps based on the most recent post's published timestamp).

Wrap up

The search feature is actually live on this website right now! Go on and take it for a spin! As the site grows in size, I'll be writing more about solving this problem at scale.