Hugo Full Text Search

Philipp | Sep 14, 2022

Showcase

You can embed the search everywhere you like 😉


Overview

To implement a full text search in my hugo site, I used a method I found in a post from weitblick.org and modified it to my needs.

It’s really simple:

  1. create index.json template
  2. modify config.toml
  3. add search.js to website
  4. create search.html and modify it if needed
  5. create search.md page

1. index.json

add index.json to ./layouts/_default

[ {{- $i := 0 -}}
{{- range where .Site.RegularPages "Section" "ne" "" -}}
   {{- if not .Params.noSearch -}}
      {{- if gt $i 0 }},{{ end -}}
      {"date":"{{ .Date.Unix }}", "url":"{{ .Permalink }}", "title":{{ .Title | jsonify  }}, "summary":{{ with .Description}}{{ . | plainify | jsonify }}{{ else }}{{ .Summary | plainify | jsonify }}{{ end }}, "content":{{ .Content | plainify | jsonify }},"tags":[ {{- $t := 0 }}{{- range .Param "tags" -}}{{ if gt $t 0 }},{{ end }}{{ . | jsonify }}{{ $t = add $t 1 }}{{ end -}} ], "section": {{ .Section | jsonify -}} }
      {{- $i = add $i 1 -}}
   {{- end -}}
{{- end -}} ]

2. config.toml

add to config.toml

[outputs]
   home = [ "HTML", "JSON" ]

In my case, I use yaml:

outputs:
  home:
    - HTML
    - JSON

3. search.js

add search.js to ./static/js/

// JSON based simple full text search with vanilla javascript via XMLHttpRequest,
// (c) 2020 by Tanja Becker - Webdeveleopment, http://tanjabecker.de/


// pass query string by CGI parameter: [URL]?query=[search string]
var query = new URLSearchParams(window.location.search).get("query");
if ((query != '') && (query != null)) {
  document.getElementById("custom-search-field").value = query;
}

// Debug mode: shows number of hits in the search result. [URL]?debug=1
let debug = new URLSearchParams(window.location.search).get("debug");

let merge_data = [];

window.addEventListener('load', function() {
  document.getElementById("custom-search").querySelectorAll("input").forEach(item => {
    item.disabled = 'disabled';
  });
  if (!merge_data.length) {
  	document.getElementById("custom-search-results").innerHTML = params['json_wait'];

    if (params['json_src'] != '') {
      var json_src_str = params['json_src'].replace("/\s/g", "");
      var json_sources = json_src_str.split(",");

      var src_count = 0;
	  for (var i = 0; i < json_sources.length; i++) {
        var xmlhttp = new XMLHttpRequest();
        var url = json_sources[i];
        xmlhttp.onreadystatechange = function() {
          if (this.readyState == 4 && this.status == 200) {
            var data = JSON.parse(this.responseText);
			for (var i = 0; i < data.length; i++) {
              merge_data.push(data[i]);
            }
            src_count++;

		    if (src_count == json_sources.length) {
	          document.getElementById("custom-search").querySelectorAll("input").forEach(item => {
                item.disabled = '';
              });

		      document.getElementById("custom-search-field").focus();
		      document.getElementById("custom-search-results").innerHTML = params['json_ready'];

              if ((query != '') && (query != null)) {
                customSearchResults();
              }
	        }
          } 
		  if (this.status == 404) {
            document.getElementById("custom-search-results").innerHTML = params['err_filefailed'];
	      }
        };
        xmlhttp.open("GET", url, true);
        xmlhttp.send();
	  }
    }
  }
}, false); 

document.getElementById("custom-search-field").addEventListener('keyup', function(e) {
  if (params['autocomplete'] == 1) {
    document.getElementById("custom-search-results").innerHTML = '';
    if (this.value == '') {
      document.getElementById("custom-search-results").innerHTML = params['err_nostring'];
      return false;
    } else if (this.value.length < params['minlength']) {
      document.getElementById("custom-search-results").innerHTML = params['err_badstring'];
      return false;
    } else if (this.value.length >= params['minlength']) {
      customSearchResults();
    }
  } else {
    if (this.value == '') {
      document.getElementById("custom-search-results").innerHTML = params['err_nostring'];
	}
  }
});

let sForm = document.getElementById("custom-search");

if (params['defaultsearch'] != '') {
  sForm.querySelectorAll("input[name='option']").forEach(item => {
    item.addEventListener('click', event => {
      customSearchResults();
    });
  });
}

let sSection = '';
if ((params['section_search'] == 1) && (sForm.elements["section[]"])) {
  sSection = sForm.elements["section[]"];
  sForm.querySelectorAll("input[name='section[]']").forEach(item => {
    item.addEventListener('click', event => {
      if (item.value == 'site') {
        if (item.checked) {
          sForm.querySelectorAll("input[name='section[]']").forEach(node => {
            node.checked = false;
          });
          sForm.querySelector("input[value='site']").checked = true;
          customSearchResults();
        }
      } else {
        if (item.checked) {
          sForm.querySelector("input[value='site']").checked = false;
        } else {
          var section_checked = [];
          for (var i = 0; i < sSection.length; i++) {
            if ((sSection[i].checked) && (sSection[i].value != 'site')) {
              section_checked.push(sSection[i].value);
            } else {
              continue;
            }
          }
          if (section_checked.length < 1) sForm.querySelector("input[value='site']").checked = true;
        }
      }
      if (document.getElementById("custom-search-field").value != '') {
        customSearchResults();
      }
    });
  });
}

function customSearchResults() {
  if (!merge_data.length) {
    return false;
  }
  var sOutput = document.getElementById("custom-search-results");
  var sString = document.getElementById("custom-search-field").value;
  if (sString == '') {
    sOutput.innerHTML = params['err_nostring'];
    return false;
  }
  if (sString.length < params['minlength']) {
    sOutput.innerHTML = params['err_badstring'];
    return false;
  }

  var data = merge_data;

  var add_searchlink = params['add_searchlink'];
  if ((add_searchlink) && (add_searchlink != '')) {
    add_searchlink = add_searchlink.replace(/\[QUERY\]/g, sString);
  }

  var sOption = '';
  var optionField = document.forms["custom-search"]["option"];
  if (optionField) {
    sOption = optionField.value;
  } else {
    sOption = params['defaultsearch'];
  } 

  var badwords = [];
  if (params['badwords'] != '') {
    var badwords_str = params['badwords'];
    badwords_str = badwords_str.replace("/\s/g", "");
    badwords = badwords_str.split(",");
  }

  var words = [];
  var search_words = sString.split(" ");
  for (var i = 0; i < search_words.length; i++) {
    if (badwords.includes(search_words[i])) {
      continue;
    } else {
      words.push(search_words[i]);
    }
  }
  if (!words.length) {
    sOutput.innerHTML = params['err_badstring'];
    return false;
  }

  var section_filter = [];
  if (sSection != '') {
    for (var i = 0; i < sSection.length; i++) {
      if ((sSection[i].checked) && (sSection[i].value != 'site')) {
        section_filter.push(sSection[i].value);
      } else {
        continue;
      }
    }
  }

  var results = [];
  for (var i = 0; i < data.length; i++) {
    var title   = data[i].title;
    var summary = data[i].summary;
    var content = data[i].content;
    var tags    = data[i].tags;
    var section = data[i].section;

    if (section_filter.length >= 1) {
      if (!section_filter.includes(section))  {
        continue;
      }
    }

    var searchtext = '';
    if (title != '')   searchtext += title;
    if (summary != '') searchtext += ' '+summary;
    if (content != '') searchtext += ' '+content;
    if (tags != '')    searchtext += ' '+tags;

    var matched = 0;
    var matches = 0;
    var matches_calc = 0;
    var title_matches = 0;
    var tags_matches = 0;
    var summary_matches = 0;
    var content_matches = 0;
    for (var y = 0; y < words.length; y++) {
      var searchword = new RegExp(words[y], 'gi');
      if (searchword.test(searchtext) === true) {
        var wordmatches = searchtext.match(searchword);
        matches = matches + wordmatches.length;
        matches_calc = matches;

        for (var key in searchfield_weight) {
          var val = searchfield_weight[key];
          var searchstr = '';
          var count = '';
          if (key == 'title') searchstr = title;
          if ((key == 'tags') && (tags != '')) searchstr = tags.join(",");
          if (key == 'summary') searchstr = summary;
          if (key == 'content') searchstr = content; 
           
          if (searchword.test(searchstr) === true) {
            matches_calc = matches_calc + val;
            count = searchstr.match(searchword).length;
            if (key == 'title') title_matches = title_matches + count;
            if ((key == 'tags') && (tags != '')) tags_matches = tags_matches + count;
            if (key == 'summary') summary_matches = summary_matches + count;
            if (key == 'content') content_matches = content_matches + count; 
          }           
        }
        matched++;
      }
    }
    data[i]['matches_calc'] = matches_calc;
    if ((debug != '') && (debug != null)) {
      data[i]['matches'] = matches;
      data[i]['title_matches'] = title_matches;
      data[i]['tags_matches'] = tags_matches;
      data[i]['summary_matches'] = summary_matches;
      data[i]['content_matches'] = content_matches;
    }
    if (sOption == 'OR') {
      if (matched >= 1) {
        results.push(data[i]);
      }
    } else {
      if (matched == words.length) {
        results.push(data[i]);
      }
    }
  } 

  // results
  if (results.length >= 1) {
    results.sort(function(a, b) {
      if (params['sort_date'] == 'DESC') {
        return b.matches_calc - a.matches_calc || b.date - a.date;
      } else {
        return b.matches_calc - a.matches_calc || a.date - b.date;
      }
    }); 

    var results_header = '';
    if (results.length > 1) {
      results_header = params['res_more_items'];
    } else {
      results_header = params['res_one_item'];
    } 
    results_header = results_header.replace("[CNT]", results.length);
    if ((add_searchlink) && (add_searchlink != '')) {
      results_header += add_searchlink;
    }

    var tag_top = params['res_out_top'];
    var tag_bottom = params['res_out_bottom'];

    var results_content = '';
    for (var i = 0; i < results.length; i++) {
      var title   = results[i].title;
      var summary = results[i].summary;
      var date    = results[i].date;
      var url     = results[i].url;
      var tags    = results[i].tags;
      var section = results[i].section;
      var extern  = results[i].extern;

      // only for debug mode
      var infos = '';
      if ((debug != '') && (debug != null)) {
        var matches         = results[i].matches;
        var matches_calc    = results[i].matches_calc;
        var title_matches   = results[i].title_matches;
        var tags_matches    = results[i].tags_matches;
        var summary_matches = results[i].summary_matches;
        var content_matches = results[i].content_matches;

        var plus_title   = title_matches >=1   ? '+ '+searchfield_weight['title']   : '+ 0';
        var plus_tags    = tags_matches >=1    ? '+ '+searchfield_weight['tags']    : '+ 0';
        var plus_summary = summary_matches >=1 ? '+ '+searchfield_weight['summary'] : '+ 0';
        var plus_content = content_matches >=1 ? '+ '+searchfield_weight['content'] : '+ 0';

        infos += '<table style="font-size: 12px; color: #666666;">';
        infos += '<tr><td style="width: 100px;"><b>Gesamt:</b></td><td style="text-align: right;">'+matches+'</td></tr>';
        infos += '<tr><td>Title ('+title_matches+'):</td><td style="text-align: right;">'+plus_title+'</td></tr>';
        infos += '<tr><td>Tags ('+tags_matches+'):</td><td style="text-align: right;">'+plus_tags+'</td></tr>';
        infos += '<tr><td>Summary ('+summary_matches+'):</td><td style="text-align: right;">'+plus_summary+'</td></tr>';
        infos += '<tr><td>Content ('+content_matches+'):</td><td style="text-align: right;">'+plus_content+'</td></tr>';
        infos += '<tr><td><b>Gesamt neu:</b></td><td style="text-align: right;">'+matches_calc+'</td></tr>';
        infos += '</table><br>';
      }

      if ((extern == 1) && (params['extern_icon'])) {
        title += ' '+params['extern_icon'];
        url = url + '" target="_blank"';
      }

      if (section != '') {
        if (section_trans[section] != undefined) {
          section = section_trans[section];
        }
      }

      var newdate = new Date(date*1000);
      var datestr = newdate.toLocaleDateString();

      var templ = params['res_item_tpl'];

      if (templ) {
        templ = templ.replace("[TITLE]", title);
        templ = templ.replace(/\[URL\]/g, url);

        if (templ.includes("[DATE]")) {
          if (date != '') { 
            templ = templ.replace("[DATE]", datestr);
          } else {
            templ = templ.replace("[DATE]", '');
          }
        }
        if (templ.includes("[SUMMARY]")) {
          if (summary != '') { 
            templ = templ.replace("[SUMMARY]", summary);
          } else {
            templ = templ.replace("[SUMMARY]", '');
          }
        }
        if (templ.includes("[TAGS]")) {
          if (tags != '') { 
            tags  = tags.join(",");
            tags  = tags.replace(/,/g, ", ");
            templ = templ.replace("[TAGS]", tags);
          } else {
            templ = templ.replace("[TAGS]", '');
          }
        }
        if (templ.includes("[SECTION]")) {
          if (section != '') { 
            templ = templ.replace("[SECTION]", '['+section+'] ');
          } else {
            templ = templ.replace("[SECTION]", '');
          }
        }
        results_content += templ;
        if (infos != '') {
          results_content += '<li>'+infos+'</li>';
        }
      }
    } 
    sOutput.innerHTML = results_header+tag_top+results_content+tag_bottom;
  } else {
    var noresult = '';
    if ((add_searchlink) && (add_searchlink != '')) {
      noresult += add_searchlink;
    }
    noresult += params['err_noresult'];
    sOutput.innerHTML = noresult;
  }
}

4. search.html

add search.html to ./layouts/shortcodes/

<form id="custom-search" name="custom-search" method="post" action="" onsubmit="customSearchResults(); return false;">
    <p>
    <input id="custom-search-field" type="text" name="search" value="" title="Search String" placeholder="Search">
    <!-- <input type="submit" value="Suchen"> -->
    </p>
    <!-- <p><em>durchsuchen:</em><br>
        <input type="checkbox"name="section[]" value="site" checked="checked"> alles<br>
        <input type="checkbox" name="section[]" value="post"> blog<br>
        <input type="checkbox" name="section[]" value="other-section"> other section
    </p> -->
    <!-- <p> -->
        <!-- <input type="radio" name="option" value="AND" checked="checked"> UND-Suche<br> -->
        <!-- <input type="radio" name="option" value="OR"> ODER-Suche -->
    <!-- </p> -->
</form>

<div id="custom-search-results"></div>

<script>
// CUSTOM AREA
let params = {
    json_src       : '/index.json', // for multiple sources: comma separated list of JSONarrays
    minlength      : 3,
    defaultsearch  : 'AND',
    sort_date      : 'DESC',
    autocomplete   : 1, // 0: form needs a submit button
    section_search : 0, // 1: needs checkboxes with name="section[]"
    badwords       : 'and,or,but,if,so,the,that,one,of', //ignore this words
    json_wait      : '<p><em>Searching...</em></p>',
    json_ready     : '<p><em>Please enter your search keywords.</em></p>',
    extern_icon    : ' (externer Link)', // marker for external links (optional)
    err_badstring  : '<p><em>Search term too short!</em></p>',
    err_noresult   : '<p><em>Sorry, no search result. Please try again.</em></p>',
    err_norequest  : '<p style="text-align: center; color:red;">The full text search is currently not available.</p>',
	err_nostring   : '<p><em>Please enter your search keywords.</em></p>',
    err_filefailed : '<p style="text-align: center;color: red;">A file could not be retrieved.</p>',
    res_one_item   : '<p><em>[CNT] SEARCH RESULT</em></p>',
    res_more_items : '<p><em>[CNT] SEARCH RESULTS</em></p>',
    res_out_top    : '<ul>',
    res_out_bottom : '</ul>',
    res_item_tpl   : '<li><a href="[URL]">[TITLE]</a><br>[DATE]:[SUMMARY]<br><em>[SECTION][TAGS]</em></li>',
    <!-- add_searchlink : '<p><a href="https://duckduckgo.com/?q=site:yourdomain.com [QUERY]" target="_blank"><i>Nicht zufrieden mit den Suchergebnissen? Externe Suche via DuckDuckGo ...</i></a></p>' -->
};

// Translation of section name (optional)
let section_trans = {
    "post" : "Blog",
    // "other-section" : "Other Section"
};

let searchfield_weight = {
    "title"   : 5,
    "tags"    : 5,
    "summary" : 2,
    "content" : 1
};
// CUSTOM AREA END
</script>
<script type="text/javascript" src="/js/search.js"></script>

5. search.md

add search.md page

---
title: "Search"
summary: "Search blog for keywords"
toc: false
socialShare: false
date: false
---

{{< search >}}