My blog is my second brain. I heap a load of things in here which I definitely will or definitely might need again. I have all-sorts; cheat sheets, one-liners and obscure commands which I use maybe once a year. Remembering all that takes some effort. Alas, my brain cells can only cope with so much.
I therefore use search. Victoria.dev is the one who, with their post on implementing search in Hugo, got me started and that is what I have used thus far. Thanks, Vic!
My need to personalise things has (again) taken over and I’ve now reached Point B, with a search function which satisfies me.
Without further ado, follow victoria.dev’s most excellent instructions and you will be OK. For the Cybrkyd fork, walk this way.
The setup
My set-up involves the same six files and directory structure, detailed below.
HUGO root:
.
├── content
│ └── search
│ └── _index.md
├── layouts
│ ├── partials
│ │ ├── search-form.html
│ │ └── search-index.html
│ └── search
│ └── list.html
└── static
└── js
├── lunr.min.js
└── search.js
Add search to Hugo
In /content/search create _index.md
and add the following to it:
---
title: search
---
Next, create /layouts/partials/search-form.html and add:
<form id="search"
action='{{ with .GetPage "/search" }}{{.Permalink}}{{end}}' method="get">
<label hidden for="search-input">Search site</label>
<input type="text" id="search-input" name="query"
placeholder="Type here to search">
<input type="submit" value="search">
</form>
<div class="clear"> </div>
Make /layouts/partials/search-index.html and add:
<script>
window.store = {
{{ range where .Site.Pages "Section" "post" }}
"{{ .Permalink }}": {
"title": "{{ .Title }}",
"content": {{ .Content | plainify }},
"url": "{{ .Permalink }}"
},
{{ end }}
}
</script>
<script src="/js/lunr.min.js"></script>
<script src="/js/search.js"></script>
Note: Depending on your Hugo setup, modify {{ range where .Site.Pages "Section" "post" }}
to posts or blog or whatever it is that your setup uses for blog posts and articles.
You are 50% of the way there!
Now, create /layouts/search/list.html and add:
{{ define "main" }}
{{ partial "search-form.html" . }}
<div class="container" role="main">
<div class="row">
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
<div class="well">
<ul id="results">
<li>Enter keywords above to search this site.</li>
</ul>
</div>
</div>
</div>
</div>
{{ partial "search-index.html" . }}
{{ end }}
The file list.html
is your search page. The four <div>
elements with classes in the above are my own layout, so tailor your page to match your theme.
lunr.js
Grab lunr.js from source and minify it. The file is around 97.5 KB and minifying will compress it down to around 39 KB (-60%). Call it lunr.min.js
and save it in /static/js/lunr.min.js.
search.js
Finally, create /static/js/search.js and add the below. I’ve heavily-commented what everything does, so feel free to remove those lines.
My enhancement highlights the searched-for keyword(s) in context; my previous implementation would always return the first 150 words of each matching article.
// This function wraps matched search terms in a span for highlighting
function highlightTerm(text, term) {
// If either term or text is empty or undefined, just return the original text
if (!term || !text) return text;
// Escape special characters to safely use it in RegExp
// e.g., if a search is like "C++", it needs to be escaped properly
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Create a case-insensitive, global RegExp using the escaped term
const regex = new RegExp(escapedTerm, 'gi');
// Replace each match with the same text wrapped in a <span> with a class
// This allows us to style it with CSS (e.g., yellow background)
return text.replace(regex, match => `<span class="search-highlight">${match}</span>`);
}
// This function creates a short preview (snippet) of the content with the term highlighted
function getSnippetWithHighlight(content, term, snippetLength = 150) {
// If no search term is provided, return the first 150 characters of content + ellipsis
if (!term) return content.substring(0, snippetLength) + '...';
// Escape special characters in the term just like before
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedTerm, 'gi');
// Try to find the first match of the term in the content
const match = regex.exec(content);
// If no match is found, just return the beginning of the content
if (!match) return content.substring(0, snippetLength) + '...';
// Calculate where the snippet should start:
// We center the snippet around the match to see the term in context
const startPos = Math.max(0, match.index - snippetLength / 2);
const endPos = Math.min(content.length, match.index + snippetLength / 2);
// Extract the snippet from the content
let snippet = content.substring(startPos, endPos);
// Highlight the matched term(s) within the snippet
snippet = highlightTerm(snippet, term);
// Add ellipses if the snippet was cut from the middle of the content
if (startPos > 0) snippet = '...' + snippet;
if (endPos < content.length) snippet = snippet + '...';
return snippet;
}
// This function displays the search results on the page
function displayResults(results, store) {
// Find the container where we will put our search results
const searchResults = document.getElementById('results');
// Get the search query from the URL parameters (e.g., ?query=javascript)
const query = new URLSearchParams(window.location.search).get('query');
if (results.length) {
// We'll build up an HTML string of list items
let resultList = '';
// Loop through each search result
for (const n in results) {
// Get the corresponding content from the "store" using the result ref (id)
const item = store[results[n].ref];
// Highlight search terms in the title
const highlightedTitle = highlightTerm(item.title, query);
// Create a content snippet with highlighted terms
const snippet = getSnippetWithHighlight(item.content, query);
// Append this result to the list as a list item
resultList += `
<li>
<p><a href="${item.url}">${highlightedTitle}</a></p>
<p>${snippet}</p>
</li>
`;
}
// Add the list of results to the page
searchResults.innerHTML = resultList;
} else {
// If there were no results, show a friendly message
searchResults.innerHTML = '<li>No results found.</li>';
}
}
// --- SEARCH INITIATION ---
// Get query string parameters from the current page URL
const params = new URLSearchParams(window.location.search);
const query = params.get('query');
if (query) {
// If a query exists, populate the search input field with the query value
// This way, users can see what they searched for in the input box
document.getElementById('search-input').setAttribute('value', query);
// Build the Lunr.js search index
// Lunr allows us to search through a list of items in memory (no server needed)
const idx = lunr(function () {
// Set the unique identifier for each item in the index
this.ref('id');
// Define which fields to index and their importance (boost)
this.field('title', { boost: 15 }); // Title is most important
this.field('tags'); // Tags have default relevance
this.field('content', { boost: 10 }); // Content is important, but less than title
// Loop through every item in the global store and add it to the index
for (const key in window.store) {
this.add({
id: key,
title: window.store[key].title,
tags: window.store[key].category, // Assuming tags are stored under "category"
content: window.store[key].content
});
}
});
// Run the search using the user's query
const results = idx.search(query);
// Show the results on the page
displayResults(results, window.store);
}
Don’t forget to add the styling for the highlighting to CSS. I chose this particular colour to match my in-line code highlighting on my pages.
.search-highlight {
background-color: #ffe0e0;
padding: 0 2px;
border-radius: 3px;
}
I’m attaching the full, original search.js file for posterity as it seems to have disappeared from victoria.dev’s archives.
And there we have it, search on a Hugo static website with pretty highlighting. My website uses this so feel free to take it for a test drive.
Get in touch via my about page if you have any comments, suggestions or questions on this or anything else for that matter.