Posts

Add basic search to your Hugo website

Sep 11, 2021 | 4 minutes read

Tags: tips, hugo

You want to add a basic search functionality to your Hugo website, however the suggestions from the official documentation feels a bit too much for your needs. Like if you want to search by one parameter only, or you don’t want to install additional packages, then this quick tutorial is a decent way to achieve this.

Full Disclosure on Hugo: I perhaps have less than 24 actual hours of experience with Hugo, just used a theme and modified it a bit to get what I want. I apologize in advance as how I do things here aren’t best practice in Hugo.

  • In your theme’s layouts > partials folder, create the html that houses the search input and the results (I’m not even sure if this folder structure is standard).
1
2
3
4
5
6
7
8
<!--The css styles can be ignored.-->
<div id="search-container" class="columns">
    <div class="column is-three-quarters-desktop">
        <input placeholder="Search all posts..." id="search-input" class="input"/>
        <div id="search-result">
        </div>
    </div>
</div>
  • Inside the layouts > _default folder, create an index.json, this will have the data we will search client-side
1
2
3
4
5
{{ $.Scratch.Add "index" slice }}
{{ range where .Site.RegularPages "Section" "blog" }}
{{ $.Scratch.Add "index" (dict "title" .Title "permalink" .Permalink "tags" .Params.tags ) }}
{{ end }}
{{ $.Scratch.Get "index" | jsonify }}

Note: We can get all the regular pages there on the 2nd line, or filter the range to something else. As for the index, add only the values you want to search (title, link and tags for my case), to keep it as small as possible

  • In your config.toml, tell your home page to output json as well
 [outputs]
    home = ['html','json']
  • In your assets folder, add the JavaScript file that will do the searching
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
(() => {
    let searchIndex = null;

    // populate the search index object
    const request = new XMLHttpRequest();
    request.onreadystatechange = () => {
        // 4 - done request, 200 - OK
        if (request.readyState === 4 && request.status === 200) {
            searchIndex = JSON.parse(request.responseText);
        }
    }
    request.open('GET', '/index.json');
    request.send();

    const search = async (query, index) => {
        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
        const regex = new RegExp(query.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
        const keys = ['permalink','tags','title']; //can add other keys here if wanted
        
        // get only the stuff that matches the regex pattern
        const result = index.filter((value) => {
            for (const key of keys) {
                if (!value[key]) continue;

                if (Array.isArray(value[key])){ 
                    // search each value in the nested array
                    if (value[key].some(v => regex.test(v.toLowerCase()))) return true;
                }else if(regex.test(value[key].toLowerCase())) return true;
            }
            
            return false;
        });

        // put the results in a list that's rendered on the screen
        if (result.length > 0) {
            const ul = document.createElement('ul');
            ul.setAttribute('class','pt-1');

            result.forEach(item => {
                const li = document.createElement('li');
                const aTag = document.createElement('a');

                li.setAttribute('class','px-1 py-1');
                aTag.setAttribute('href', item.permalink);
                aTag.setAttribute('class', 'is-block');
                aTag.innerHTML = item.title;

                li.appendChild(aTag);
                ul.appendChild(li);
            })

            return ul
        }

        return null;
    }

    const searchResult = document.getElementById('search-result');
    const searchInput = document.getElementById('search-input');

    // search on input
    searchInput?.addEventListener('input', async (e) => {
        let result = null;
        if (e.currentTarget.value) result = await search(e.currentTarget.value, searchIndex);
        if (searchResult.lastChild) searchResult.removeChild(searchResult.lastChild);
        if (result) searchResult.appendChild(result);
    });

    // clear the search input - this is so when you press back in your browser, 
    // the search input is cleared nicely
    searchInput?.addEventListener('focusout', (e) => {
        e.target.value = '';
        // timeout is there because removing the element instantly won't let you hit the anchor tag
        setTimeout(() => {
            if (searchResult.lastChild) searchResult.removeChild(searchResult.lastChild);
        }, 150);
    });
})();
  • Then call this script somewhere in your homepage, in my case it is on another partial page
1
2
3
4
5
6
7
8
<!-- At the home page -->
{{ partial "footer/scripts.html" . }}
   
<!-- At the partial page. layouts > partials > footer folder  -->
{{ range $value := .Site.Params.customJS }}
    {{ $script := resources.Get . | js.Build (dict "minify" true) }}
    <script src="{{ $script.Permalink }}"></script>
{{ end }}

Add your own styling to finish it off. Here is a working demo on my site’s blog area.

One downside I can imagine is when there are lots of pages in your site, the index file will be big, which will slow down this solution. If you also search the contents of each page, the index file can get bigger even faster. For small sites however (I’m guessing less than 5,000 pages without searching the page’s content), this solution should work pretty well.