Add search to Hugo multilingual static site with Lunr
On This Page
Initial
I had the need to implement search functionality on my site. Content on is in different languages.
The goal is to impelemnt search for all pages and separate search results for each and every language.
How it works
Hugo generates the search index. In this case it means that we get json file with every static page on the site.
To make search works we need to create index. lunr.js takes care of it.
Client send query -> our script “tries to find” in the index
Render the results
This is how the logic looks like:
Implementation
- Create search form
- Create popup modal where will render search results
- Connect Lunr.js script
- Generate pages data
- Connect search/result forms with lunr.js search
TL;DR
Files to change/create:
1. `/layouts/partials/header.html`
<form id="search">
<input type="text" type="search" id="search-input">
</form>
2. `/layouts/partials/components/search-list-popup.html`
<div id="search-result" tabindex="-1"
class="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 max-w-xs " hidden>
<div class="relative p-4 w-full max-w-xs h-full md:h-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6">
<h3>Search results</h3>
<div id="search-results" class="prose"></div>
</div>
</div>
</div>
</div>
3. `/layouts/partials/footer.html`
...
{{ $languageMode := .Site.Language }}
<script src="https://unpkg.com/lunr/lunr.min.js"></script>
<script src="/js/search.js?1" languageMode={{ $languageMode }} ></script>
{{ partial "components/search-list-popup.html" . }}
...
4. `/layouts/_default/index.json`
[
{{- range $index, $page := .Site.RegularPages.ByTitle -}}
{{- if gt $index 0 -}} , {{- end -}}
{{- $entry := dict "uri" $page.RelPermalink "title" $page.Title -}}
{{- $entry = merge $entry (dict "description" .Description) -}}
{{- $entry = merge $entry (dict "content" (.Plain | htmlUnescape)) -}}
{{- $entry | jsonify -}}
{{- end -}}
]
5. `config.yaml`
# config.yaml
# need for search popup service / creates search.json index fo lunr.js
outputFormats:
SearchIndex:
baseName: search
mediaType: application/json
outputs:
home:
- HTML
- RSS
- SearchIndex
6. `static/js/search.js`
const languageMode = window.document.currentScript.getAttribute('languageMode');
const MAX_SEARCH_RESULTS = 10
let searchIndex = {}
let pagesStore = {}
// Need to create ONLY once , maybe before push | during build
const createIndex = (documents) => {
searchIndex = lunr(function () {
this.field("title");
this.field("content");
this.field("description");
this.field("uri");
this.ref('uri')
documents.forEach(function (doc) {
pagesStore[doc['uri']] = doc['title']
this.add(doc)
}, this)
})
}
const loadIndexData = () => {
const url = `/${languageMode}/search.json`;
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
const pages_content = JSON.parse(this.responseText);
createIndex(pages_content)
}
};
xmlhttp.open("GET", url, true);
xmlhttp.send();
}
const search = (text) => {
let result = searchIndex.search(text)
return result
}
const hideSearchResults = (event, divBlock) => {
event.preventDefault()
if (!divBlock.contains(event.target)) {
divBlock.style.display = 'none';
divBlock.setAttribute('class', 'hidden')
}
}
// TODO refactor
const renderSearchResults = (results) => {
const searchResultsViewBlock = document.getElementById('search-result')
// hide on move mouse from results block
document.addEventListener('mouseup', (e) => hideSearchResults(e, searchResultsViewBlock));
const searchResultsDiv = document.getElementById('search-results')
searchResultsDiv.innerHTML = ''
searchResultsViewBlock.style.display = 'initial';
searchResultsViewBlock.removeAttribute('hidden')
const resultsBlock = document.createElement('ul')
for (let post of results) {
const url = post['ref']
const title = pagesStore[url]
let commentBlock = document.createElement('li')
let link = document.createElement('a',)
let linkText = document.createTextNode(title);
link.appendChild(linkText)
link.href = url
commentBlock.appendChild(link)
resultsBlock.appendChild(commentBlock)
}
searchResultsDiv.appendChild(resultsBlock)
}
const searchFormObserver = () => {
var form = document.getElementById("search");
var input = document.getElementById("search-input");
form.addEventListener("submit", function (event) {
event.preventDefault();
var term = input.value.trim();
if (!term) {
return
}
const search_results = search(term, languageMode);
renderSearchResults(search_results.slice(0, MAX_SEARCH_RESULTS))
}, false);
}
// create indexes
loadIndexData()
searchFormObserver()
Search form
I am going to add search form to the header part. For thios purpose edit header.html
file in the path /layouts/partials/header.html
Set form id: search
. By this id script can find this form
Minimal form for work:
<form id="search">
<input type="text" type="search" id="search-input">
</form>
I use Tailwind, so this is how my form looks like:
<div class="relative pt-4 md:pt-0">
<form id="search" class="flex items-center">
<label for="search-input" class="sr-only">Search</label>
<div class="relative w-full">
<input type="text" type="search" id="search-input" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Search" required>
</div>
</form>
</div>
Modal with results
By default this modal window is hidden. So don’t need to add this to any page. But need to add somewhere.
1. Create .html component
path: /layouts/partials/components/search-list-popup.html
For modal block to show or hide I use id: search-result
For block with search results id is: search-results
Content:
<div id="search-result" tabindex="-1"
class="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 max-w-xs " hidden>
<div class="relative p-4 w-full max-w-xs h-full md:h-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6">
<h3>Search results</h3>
<div id="search-results" class="prose"></div>
</div>
</div>
</div>
</div>
2. Add component to the site
Add this component to the footer. File path: /layouts/partials/footer.html
...
{{ partial "components/search-list-popup.html" . }}
...
Connect Lunr.js
Add link to this script to the footer template too
Part of the footer template:
...
<script src="https://unpkg.com/lunr/lunr.min.js"></script>
{{ partial "components/search-list-popup.html" . }}
...
Generate pages data
Hugo can generate the search index the same way it generates RSS feeds for example, it’s just another output format.
1. Generate script
This generator is for multilingual site
Creates json in each language catalog in format:
[{"title":"title01",...}]
Fepends on fileds inckluded in the layout /layouts/_default/index.json
Create file /layouts/_default/index.json
[
{{- range $index, $page := .Site.RegularPages.ByTitle -}}
{{- if $page.IsTranslated -}}
{{ if gt (index $page.Translations 0).WordCount 0 }}
{{ range .Translations }}
{{- if gt $translatedCount 0 -}} , {{- end -}}
{{- $entry := dict "uri" .RelPermalink "title" .Title -}}
{{- $entry = merge $entry (dict "description" .Description) -}}
{{- $entry = merge $entry (dict "content" (.Plain | htmlUnescape)) -}}
{{- $entry | jsonify -}}
{{ $translatedCount = add $translatedCount 1 }}
{{ end}}
{{ end }}
{{- end -}}
{{- end -}}
]
Creates search.json file with page indexes in /public/search.json
2. Set index file path
Update config.yaml
file:
# config.yaml
# need for search popup service / creates search.json index fo lunr.js
outputFormats:
SearchIndex:
baseName: search
mediaType: application/json
outputs:
home:
- HTML
- RSS
- SearchIndex
Connect search/result forms with lunr.js search
Create file in the path: static/js/search.js
const languageMode = window.document.currentScript.getAttribute('languageMode');
const MAX_SEARCH_RESULTS = 10
let searchIndex = {}
let pagesStore = {}
// Need to create ONLY once , maybe before push | during build
const createIndex = (documents) => {
searchIndex = lunr(function () {
this.field("title");
this.field("content");
this.field("description");
this.field("uri");
this.ref('uri')
documents.forEach(function (doc) {
pagesStore[doc['uri']] = doc['title']
this.add(doc)
}, this)
})
}
const loadIndexData = () => {
const url = `/${languageMode}/search.json`;
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
const pages_content = JSON.parse(this.responseText);
createIndex(pages_content)
}
};
xmlhttp.open("GET", url, true);
xmlhttp.send();
}
const search = (text) => {
let result = searchIndex.search(text)
return result
}
const hideSearchResults = (event, divBlock) => {
event.preventDefault()
if (!divBlock.contains(event.target)) {
divBlock.style.display = 'none';
divBlock.setAttribute('class', 'hidden')
}
}
// TODO refactor
const renderSearchResults = (results) => {
const searchResultsViewBlock = document.getElementById('search-result')
// hide on move mouse from results block
document.addEventListener('mouseup', (e) => hideSearchResults(e, searchResultsViewBlock));
const searchResultsDiv = document.getElementById('search-results')
searchResultsDiv.innerHTML = ''
searchResultsViewBlock.style.display = 'initial';
searchResultsViewBlock.removeAttribute('hidden')
const resultsBlock = document.createElement('ul')
for (let post of results) {
const url = post['ref']
const title = pagesStore[url]
let commentBlock = document.createElement('li')
let link = document.createElement('a',)
let linkText = document.createTextNode(title);
link.appendChild(linkText)
link.href = url
commentBlock.appendChild(link)
resultsBlock.appendChild(commentBlock)
}
searchResultsDiv.appendChild(resultsBlock)
}
const searchFormObserver = () => {
var form = document.getElementById("search");
var input = document.getElementById("search-input");
form.addEventListener("submit", function (event) {
event.preventDefault();
var term = input.value.trim();
if (!term) {
return
}
const search_results = search(term, languageMode);
renderSearchResults(search_results.slice(0, MAX_SEARCH_RESULTS))
}, false);
}
// create indexes
loadIndexData()
searchFormObserver()
Next need to add this file to the site: /layouts/partials/footer.html
Now footer looks like this:
...
{{ $languageMode := .Site.Language }}
<script src="https://unpkg.com/lunr/lunr.min.js"></script>
<script src="/js/search.js?1" languageMode={{ $languageMode }} ></script>
{{ partial "components/search-list-popup.html" . }}
...