Enhancing client-side search with Lunr.js
When building a blog or website, having a fast and efficient search feature is essential for helping users find the content they need. For my site, I chose Lunr.js, a lightweight and powerful JavaScript library for full-text search. In this article, I’ll walk you through how I implemented Lunr.js to power the search functionality on my site. We won’t consider efficient caching methods in this article, but we can explore them in a future post.
The search UI
To make the search experience seamless, I created a modal-based search interface. The modal contains an input field for the search query and a list to display the results. Here’s the HTML structure:
<div id="search-modal" class="modal">
<div class="modal-content">
<input type="text" id="search-input" placeholder="🚀 Search for a blog post...">
<span class="close">×</span>
<ul id="search-results"></ul>
</div>
</div>
The modal is styled to appear as an overlay, and the search results dynamically update as the user types in the input field. To get a sense of how it works, click the search icon or press CTRL + K.
Setting up Lunr.js
Instead of using a JSON file for the search index, I generate the index dynamically using a window.store
object. This object is populated with metadata for all my blog posts during the build process. Here’s how I define it in my template:
<script>
// Template to generate the search index
window.store = {
{% for post in site.posts %}
"{{ post.url | slugify }}": {
"title": "{{ post.title | xml_escape }}",
"date": {{ post.date | date: "%b %-d, %Y" | jsonify }},
"author": "{{ post.author | xml_escape }}",
"category": "{{ post.category | xml_escape }}",
"content": {{ post.content | strip_html | jsonify }},
"url": "{{ post.url | xml_escape }}"
}
{% unless forloop.last %},{% endunless %}
{% endfor %}
};
</script>
This window.store
object contains all the necessary data for Lunr.js to index and search through my blog posts.
Initializing Lunr.js and handling search queries
I use Lunr.js to create the search index directly in the browser. Since I don’t use a CDN, I include the Lunr.js file locally in my project:
<script src="/dario_blog/assets/js/lunr.min.js"></script>
<script src="/dario_blog/assets/js/search.js"></script>
<script src="/dario_blog/assets/js/modal.js"></script>
Here’s the JavaScript code I use to initialize Lunr.js and handle search queries:
(function () {
let idx; // Declare a global variable to store the Lunr.js index
function isModalShown(modal) {
return modal.style.display === 'block';
}
function showResults(results, store) {
const searchResults = document.getElementById('search-results');
if (results.length) { // If there are results...
let appendString = '';
for (let i = 0; i < results.length; i++) { // Iterate over them and generate html
const item = store[results[i].ref];
appendString += '<li><a href="'+ BASEURL + item.url + '" style="display: block; text-decoration: none; color: inherit;">';
appendString += '<span class="post-meta">' + item.date + '</span><h2>' + item.title + '</h2>';
appendString += '<p class="small">' + item.content.substring(0, 100) + '...</p>';
appendString += '</a></li>';
}
searchResults.innerHTML = appendString;
} else {
searchResults.innerHTML = '<li>No results found</li>';
}
}
function createIndex() {
// Initialize lunr.js with the fields to search
const index = lunr(function () {
this.field('id');
this.field('title', { boost: 10 });
this.field('author');
this.field('category');
this.field('content');
for (var key in window.store) { // Add the JSON generated from site content to Lunr.js
this.add({
'id': key,
'title': window.store[key].title,
'author': window.store[key].author,
'category': window.store[key].category,
'content': window.store[key].content
});
}
});
// Save the index to session storage
sessionStorage.setItem('lunrIndex', JSON.stringify(index));
return index;
}
function loadIndex() {
const savedIndex = sessionStorage.getItem('lunrIndex');
if (savedIndex) {
return lunr.Index.load(JSON.parse(savedIndex));
}
return null;
}
function doSearch(searchTerm) {
if (!searchTerm) return;
// Check if the index has already been created
if (!idx) {
idx = loadIndex() || createIndex();
}
const results = idx.search(searchTerm); // Perform search with Lunr.js
showResults(results, window.store);
}
const modal = document.getElementById('search-modal');
const searchBox = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
// Use MutationObserver to watch for changes in the modal's display style
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.attributeName === 'style') {
if (isModalShown(modal)) {
searchBox.addEventListener('keyup', onKeyUp);
} else {
searchBox.removeEventListener('keyup', onKeyUp);
searchBox.value = ''; // Clear the search box when the modal is hidden
searchResults.innerHTML = ''; // Clear the search results when the modal is hidden
}
}
});
});
observer.observe(modal, { attributes: true });
function onKeyUp(event) {
if (this.value.trim() === '') {
searchResults.innerHTML = ''; // Clear the search results if the search box is empty
} else {
doSearch(this.value);
}
}
})();
This script initializes the Lunr.js index using the window.store
object, dynamically updates the search results, and integrates with the modal UI.
Alternative: Using a JSON file for the index
If you prefer to use a JSON file instead of embedding the data in the window.store
object, you can generate a JSON file during the build process and load it dynamically. Here’s an example of what the JSON file might look like:
[
{
"url": "/posts/introduction-to-lunrjs",
"title": "Introduction to Lunr.js",
"content": "Lunr.js is a powerful search library for static sites..."
},
{
"url": "/posts/building-a-static-site-with-jekyll",
"title": "Building a Static Site with Jekyll",
"content": "Jekyll is a popular static site generator..."
}
]
You can then load this file in your JavaScript:
fetch('/search-index.json')
.then(response => response.json())
.then(data => {
const idx = lunr(function () {
this.ref('url');
this.field('title');
this.field('content');
data.forEach(doc => this.add(doc));
});
// Handle search input as shown earlier
});
Alternative: Using a CDN for Lunr.js
If you don’t want to host Lunr.js locally, you can include it via a CDN:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lunr.js/2.3.9/lunr.min.js"></script>
This is a quick and easy way to get started with Lunr.js.
Conclusion
Lunr.js is an excellent choice for adding search functionality to static sites. By using a window.store
object, I’ve made the process of creating a search index directly in the browser simple and effective. For those who prefer a different approach, using a JSON file or a CDN are great alternatives.
With Lunr.js, I’ve created a fast and efficient search experience for my readers, all without requiring a backend. If you’re building a static site, I highly recommend giving Lunr.js a try!
If you have any questions or need help setting it up on your site, feel free to reach out. You can also check my GitHub repository for the full solution.