Recently experimented with vanilla JS and a basic Web Worker to set up a search for a static website (but not only).
In this post Iโll go through the basic idea, implementation and examples to set up a blog search for Eleventy, Hugo, Jekyll and other static-site generators.
Browser - Web Worker flow visualized
Detailed steps
Browser: loads search script and worker ๐ค
The search script loads the worker
new Worker("/js/search.worker.js")
A keyup
event listener is attached to the search input rendered on the browser
.addEventListener('keyup', () => { ... })
To receive worker search results, the search script defines a onmessage
callback function:
worker.onmessage = () => { ... }
Worker: preloads search index ๐
On the worker side, a GET
request is made to fetch the search.json
file that contains an optimized search index
The worker also registers a onmessage
callback function to receive search requests from the browser:
onmessage = () => { ... }
Browser: User enters search term ๐
Via the worker function postMessage
a message is sent to the worker, containing the search term entered by the user.
Worker: responds with search results
Worker searches the received term in an optimized index of words linked to pages and posts.
Browser: renders search results
The browser receives a callback to the previously registered worker.onmessage
function.
The search results are shown to the user through various techniques.
Toggle visibility of search results
One technique, perhaps the lighter one, is to simply toggle the visibility of posts based on their href
and the search results, e.g.:
// hide all posts
posts$.querySelectorAll('a').forEach(r => r.classList.add('hidden'))
// show only matching ones with search results
posts$.querySelectorAll(results.map(r => `[href="${r}"]`).join(','))
.forEach(r => r.classList.remove('hidden'))
This works best if you have a search page in which you initially render all your posts and pages on which you want to perform your search on.
Rerender search results
Another approach is to rerender all search results, e.g.:
results$.innerHTML = results.map(toResultHtml).join('')
function toResultHtml(l) {
return `
<a class="block searchable-item" href="${ l.url }">
<h3 class="mt-0">${ l.title }</h3>
</a>
`.trim()
}
This works best if you have a lot of pages and you donโt necessarily have a page where you render all your pages initially
Code
To see it working in the wild, check out the /posts page!
The worker script
The full worker script:
(async function () {
const searchJson = await fetch('/search.json').then(res => res.json())
const searchKeys = Object.keys(searchJson.index)
onmessage = function (e) {
const term = e.data || ''
let results = term.split(' ').filter(Boolean).reduce((acc, curr) => {
const startMatches = searchKeys.filter(k => k.includes(curr))
if (startMatches.length > 0) {
const matches = startMatches.reduce((a, m) => {
return a.concat(searchJson.index[m].map(url => searchJson.items.find(item => item.url === url)))
}, [])
return acc.concat(matches)
}
return acc
}, [])
results = [...new Set(results)]
results.length = 100
results = results.filter(Boolean).sort((a, b) => +new Date(b.date) - +new Date(a.date))
.map(a => a.url)
postMessage({ type: 'search', results, term })
}
})()
Search script and markup
<div class="searchable">
<input class="searchable-input" type="text" placeholder="๐ Search posts"/>
<p class="status"></p>
<div class="postslist"></div>
</div>
const search$ = document.querySelector('.searchable-input')
const results$ = document.querySelector('.postslist')
const status$ = document.querySelector('.status')
if (!search$) console.error('missing search element')
if (!results$) console.error('missing results element')
if (window.Worker) {
console.log('has worker')
const worker = new Worker("/js/search.worker.js")
search$.addEventListener('keyup', function (e) {
const term = e.target.value.toLowerCase().trim()
if (term) worker.postMessage(term)
else {
status$.innerText = ''
results$.querySelectorAll('a').forEach(r => r.classList.remove('hidden'))
}
})
worker.onmessage = function(e) {
if (e.data.type === 'search') {
const results = e.data.results
const term = e.data.term
if (!Array.isArray(results)) return console.info(e.data)
results$.querySelectorAll('a').forEach(r => r.classList.add('hidden'))
results$.querySelectorAll(results.map(r => `[href="${r}"]`).join(',')).forEach(r => r.classList.remove('hidden'))
if (results.length >= 1) {
status$.innerText = `${results.length} results for ${term}`
} else {
status$.innerText = ''
}
}
}
}
create search index
this is the script I use to create the search index:
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
main()
.then(console.log)
.catch(console.error)
async function main() {
console.log('nlp')
const search = require('../_site/db.json')
const allWords = new Map()
for (const r of search) {
const words = (r.title + ' ' + (r.content || '')).split(' ').filter(Boolean)
.map(s => s.trim().replace(/,/g, '').replace(/\(/g, '').replace(/\)/g, '').toLowerCase())
.filter(Boolean)
.filter(s => s.includes('&') === false)
.filter(s => s.includes('.') === false)
.filter(s => s.includes('://') === false)
console.log('+', words.length, 'words')
for (const word of words) {
let newLinks = (allWords.get(word) || []).concat([r.url])
newLinks = [...new Set(newLinks)]
allWords.set(word, newLinks)
}
}
console.log('total words', allWords.size)
const fileContents = JSON.stringify({
items: search,
index: [...allWords.keys()].reduce((acc, curr) => {
if (Object.keys(acc).length % 100 === 0) console.log(Object.keys(acc).length)
return { ...acc, [curr]: allWords.get(curr) }
}, {})
})
fs.writeFileSync(path.resolve(__dirname, '..', 'search.json'), fileContents, { encoding: 'utf-8' })
}
It is based on this db.json
(coming from an db.njk
) file, with eleventy this would look like this:
---
permalink: db.json
eleventyExcludeFromCollections: true
---
[
{%- for post in collections.post %}
{
"title": "{{ post.data.title }}",
"url": "{{ post.url | url }}",
"date": "{{ (post.data.updated or post.date) | rssDate }}",
"content": "{{ post.templateContent | dehtml | clean | escape }}",
"tags": [
{%- for tag in post.tags %}
"{{ tag }}"{% if not loop.last %},{% endif %}
{%- endfor %}
]
}{% if not loop.last %},{% endif %}
{%- endfor %}
]
Graceful degradation
You could add some simple JS to make the search work without Web Workers. What a shame though:
;(function search () {
;[...document.querySelectorAll('.searchable')].forEach(makeSearchable)
function makeSearchable ($searchable) {
const $allSearchableItems = [...$searchable.querySelectorAll('.searchable-item')]
const $search = $searchable.querySelector('input')
let previousSearchTerm = ''
$search.addEventListener('keyup', (e) => {
const searchTerm = e.target.value
if (searchTerm === '') return $allSearchableItems.forEach($el => $el.classList.remove('hidden'))
const refining = previousSearchTerm.startsWith(searchTerm)
const $items = refining ? [...$searchable.querySelectorAll('.searchable-item:not(.hidden)')] : $allSearchableItems
$items.forEach($el => {
const text = $el.innerText
const show = new RegExp(searchTerm, 'i').test(text)
if (show) {
$el.classList.remove('hidden')
} else {
$el.classList.add('hidden')
}
})
previousSearchTerm = searchTerm
})
}
})()