For those interested in setting up site search with the current version of Hugo (15), I’ve finally got the following to work using lunr.js.
A few quick things:
- I’m running a gulp build concomitantly with hugo that includes uglify, concat, min, etc. This is for an internal-only company project, so PM me if you want my
Gulpfile.js
for any reason - This is a live search in a full-page overlay and isn’t currently set up for Google Analytics, although this is forthcoming
- You’ll need a separate call to
lunr.js
before the following scripts in the cascade. - I refused to use any jQuery because I wanted to learn without using a library as a crutch.
- I an keeping all my images in the folder respective to the content. I then copy/replace them to
static
as part of a bash script (see 5 below). This was necessary because my colleagues wanted drag and drop when creating .md files in GitHub. This almost means an additional JavaScript IIFE when you want your images to render on the client (see#6) - You will need to create
content/json/dummy.md
with yaml and some dummy content for this to work, since I think Hugo still needs at least one file in the json folder to create
1. The Search FORM HTML
layouts/partials/site_header/site_search_form.html
<!--layouts/partials/site_header/site_search_form.html-->
<!--BEGIN FORM-->
<form action="" id="site-search" class="search-form" role="search">
<a href="#" id="close-search"><i class="icon-cancel"></i></a>
<input type="search" id="search-input" aria-label="Search the {{ .Site.Title }} website..." class="site-search" autocomplete="off" placeholder="Search DST documentation..." required>
<ul id="search-results">
<!-- The following <li> can be uncommented for styling purposes, but these reflect the same css classes included in the script. -->
<!-- <li class="search-result">
<a href="#"><h5>Sample Search Result Heading</h5></a>
<p>Here is a simple description area that should be kept relatively short, but just in case, let's make this a little bit longer.</p>
<div class="in-section">Found in: Digital Content Writers Guide</div>
<ul class="tags">
<li><i class="icon-tags"></i></li>
<li><a href="#" class="tag">sushi</a></li>
<li><a href="#" class="tag">japanese</a></li>
</ul>
</li> -->
</ul>
<div class="press-escape" title="Press Escape to Close the Site Search Overlay">Press <em>ESC</em> to close.</div>
</form>
<!--END FORM-->
<!--Search toggle button (place this wherever appropriate in your source-->
<a href="#" id="search-button" role="button"><span> Press <em>S</em> to search</span><i class="icon-search"></i></a>
2. The layout/template to create site-index.html
layouts/section/json.html
<!--layouts/section/json.html-->
<!--note that I include title, subtitle (optional), tags, and description in all my content .md yaml. Once you get this down, remove the extra white space because it will cut down your site-index.json file considerably.-->
[{{ range $index, $page := .Site.Pages }}
{{ if ne $page.Type "json" }}
{{ if and $index (gt $index 1) }},{{ end }}
{
"url": "{{ $page.Permalink }}",
"title": "{{ $page.Title }}",
<!--Add content subtitle to index only if set, otherwise set to empty since subtitle is *still* added to lunr.js index.-->
{{ if $page.Params.subtitle }}
"subtitle": "{{ $page.Params.subtitle }}",
{{ else }}
"subtitle":"",
{{ end }}
"section": "{{ .Section }}",
"tags": [{{ range $tindex, $tag := $page.Params.tags }}{{ if $tindex }}, {{ end }}"{{ $tag }}"{{ end }}],
"description": "{{.Description}}",
"content": "{{$page.PlainWords}}"
}{{ end }}{{ end }}]
###3 The lunr AJAX call for the index and toggle of the full-screen search overlay
assets/js/modules/search-lunr-ajax-call.js
//I will try and add more comments to this later...
/*Begin full-screen search overlay toggle*/
//Note that this requires the element.classList method, which is not supported in IE9. If you need IE9 support, use the classList.js polyfill (https://github.com/eligrey/classList.js/)
//Full-screen overlay opens via click/touch event or if the user hits "s' on the keyboard. When search is open, this is controlled for so that people can search words with "s" in them
var searchOverlay = document.querySelector('.search-form');
var searchButton = document.getElementById('search-button');
var searchInput = document.getElementById('search-input');
var closeSearch = document.getElementById('close-search');
closeSearch.onclick = function() {
if (searchOverlay.classList.contains('open')) {
searchOverlay.classList.remove('open');
}
}
window.addEventListener('keyup', function(event) {
var keyPressed = event.keyCode;
if (keyPressed === 83 && searchOverlay.classList.contains('open')) {
return;
} else if (keyPressed === 83) {
searchOverlay.classList.add('open');
if (searchInput.value.length > 0) {
searchInput.value = '';
}
searchInput.focus();
} else if (keyPressed === 27 && searchOverlay.classList.contains('open')) {
searchOverlay.classList.remove('open');
}
}, true);
searchButton.addEventListener('click', function(event) {
searchOverlay.classList.toggle('open');
searchInput.focus();
}, true);
/*End search overlay toggle*/
/*Begin Lunr live search*/
//for more information on lunr.js, go to http://lunrjs.com/
var searchData;
var searchInput = document.getElementById('search-input');
searchInput.addEventListener('keyup', lunrSearch, true);
window.index = lunr(function() {
this.field('id');
this.field('url');
this.field('title', { boost: 50 });
this.field('subtitle');
this.field('description');
this.field('tags',{ boost: 30});
this.field('content', { boost: 10 });
//boosting for relevancy is up to you.
});
var searchReq = new XMLHttpRequest();
searchReq.open('GET', '/site-index.json', true);
searchReq.onload = function() {
if (this.status >= 200 && this.status < 400) {
console.log("Got the site index");
searchData = JSON.parse(this.response);
searchData.forEach(function(obj, index) {
obj['id'] = index;
window.index.add(obj);
});
} else {
console.log("Failed status for site-index.js. Check /static/site-index.json");
}
}
searchReq.onerror = function() {
console.log("Error when attempting to load site-index.json.");
}
searchReq.send();
function lunrSearch(event) {
var query = document.querySelector("#search-input").value;
var searchResults = document.querySelector('#search-results');
if (query.length === 0) {
searchResults.innerHTML = '';
}
if ((event.keyCode !== 9) && (query.length > 2)) {
var matches = window.index.search(query);
displayResults(matches);
}
}
function displayResults(results) {
var searchResults = document.querySelector('#search-results');
var inputVal = document.querySelector('#search-input').value;
if (results.length) {
searchResults.innerHTML = '';
results.forEach(function(result) {
var item = window.searchData[result.ref];
var section = item.section.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
var appendString = '<li class=\"search-result\"><a href=\"' + item.url + '\"><h5>' + item.title + '</h5></a><p>' + item.description + '</p><div class=\"in-section\">In: ' + section + '</div><ul class=\"tags\"><li><i class=\"icon-tags\"></i></li>';
// var tags = '';
for (var i = 0; i < item.tags.length; i++) {
appendString += '<li><a href=\"/tags/' + item.tags[i] + '\" class=\"tag\">' + item.tags[i] + '</a> ';
}
appendString += '</ul></li>';
searchResults.innerHTML += appendString;
})
} else {
searchResults.innerHTML = '<li class=\"search-result none\">No results found for <span class=\"input-value\">' + inputVal + '</span>.<br/>Please check spelling and spacing.</li>';
}
}
4. The style that gives you the full-screen search experience
assets/sass_modules/search-overlay.scss
//NOTE that the variable names are my own. You will obviously need to adjust according to how you design your sass variables.scss file. Honestly, you will probably want to write your own styling, especially since the variables and mixins are my own, but I'm throwing this in for good measure. The icon-* classes reflect [fontello](http://fontello.com/), which I used to reduce the overhead of including larger libraries like Font Awesome.
#search-button {
display: inline;
color: $base-font-color;
background-color: $top-bar-background-color;
font-size: $top-bar-height/4;
height: $top-bar-height;
line-height: $top-bar-height;
cursor: pointer;
position: absolute;
top: 0px;
right: $top-bar-height/4;
transition: all .3s ease-in-out;
&:hover {
color: $brand;
span {
color: $brand;
em {
color: $brand;
border-color: $brand;
}
}
}
span {
display: none;
@include MQ(L) {
display: inline;
height: $top-bar-height/2;
line-height: $top-bar-height/2;
}
}
em {
font-family: "Lucida Console", "Courier New", courier, monaco, monospace;
font-style: normal;
font-weight: 100;
border: 1px solid lighten($base-font-color, 35%);
padding: .25em;
border-radius: 3px;
color: $base-font-color;
}
i.icon-search {
font-size: 2em;
@include MQ(L){
font-size:1.5em;
}
}
}
form.search-form {
position: fixed;
top: 0;
left: 0;
width: 100%!important;
width: 100vw!important;
height: 100%;
height: 100vh;
display: none;
overflow-y: scroll;
&.open {
display: block;
z-index: 999;
background-color: white;
}
input#search-input {
display: block;
width: 90%;
margin-left: 5%;
margin-top: 1.5em;
@include MQ(L) {
width: 70%;
max-width: 70%;
margin-left: auto;
margin-right: auto;
margin-top: 2rem;
font-size: 2rem;
}
}
}
div.press-escape {
display: none;
@include MQ(L) {
display: block;
position: absolute;
margin-top: 20px;
font-size: 16px;
bottom: 25px;
left: calc(50% - 100px);
right: calc(50% - 100px);
width: 200px;
em {
font-family: "Lucida Console", "Courier New", courier, monaco, monospace;
font-style: normal;
font-weight: 100;
position: relative;
font-size: .7em;
border: 1px solid lighten($base-font-color, 35%);
border-radius: 3px;
color: $base-font-color;
top: -.67em;
padding: .25em .5em 1.5em;
}
}
}
#close-search {
width: 18px;
position: fixed;
top: 20px;
right: 30px;
z-index: 999;
}
ul#search-results {
display: block;
width: 90%;
margin-left: 5%;
margin-top: 1.5em;
padding-left: 0;
@include MQ(L) {
width: 70%;
margin-left: auto;
margin-right: auto;
margin-top: 2rem;
}
li {
list-style: none inside none;
border: 1px solid $search-result-border-color;
position: relative;
display: block;
height: 100%;
margin-left: 0;
margin-bottom: .75em;
padding-left: 0;
z-index: 999;
margin-left: -10px;
margin-right: -10px;
a {
&:hover {
cursor: pointer!important;
}
&:focus {
h5 {
background-color: $search-result-title-background-color-hover;
margin-left: -1px;
width: calc(100% + 2px);
}
}
}
h5 {
transition: all .2s ease-in-out;
background-color: $search-result-title-background-color;
color: white;
margin-top: -1px;
padding-top: -1px;
padding-left: 10px;
padding-right: 10px;
font-weight: bold;
&:hover,
&:focus,
&:active {
cursor: pointer!important;
background-color: $search-result-title-background-color-hover;
margin-left: -1px;
width: calc(100% + 2px);
}
}
div.in-section {
display: block;
width: 100%;
padding-left: 10px;
font-size: .75em;
display: inline-block;
color: black;
font-weight: bold;
}
p {
flex-grow: 1;
padding-left: 10px;
padding-right: 10px;
margin-top: 5px;
margin-bottom: 0px;
}
//see _icons.scss for span.tags-icon
&.none {
border: 3px solid $warning-color;
padding-left: 10px;
}
}
ul.tags {
display: none;
@include MQ(M) {
display: flex;
margin-bottom: .5rem;
margin-top: .5rem;
width: 100%;
padding-left: 10px;
justify-content: flex-end;
li {
display:none;
@include MQ(M){
display:inline;
}
border: 0px;
margin: 0 5px;
padding: 0;
//fontello icon
.icon-tags {
color: $cap-black;
}
//only show mobile-plus size since tags are hidden in search overlay for mobile.
a.tag {
font-size: $tag-font-size-tablet-plus;
background-color: $tag-background-color;
color: $tag-font-color;
&:hover {
background-color:$tag-background-color-hover;
}
}
}
}
}
}
i.icon-cancel {
color: $white;
font-size: 1.2rem;
background-color: $base-font-color;
border-radius: 50%;
}
span.input-value {
color: $warning-color;
font-weight: bold;
font-style: italic;
}
###5. The Bash Script that puts it all together
hugo-build-index-and-serve.sh
# HUGO BUILD SITE AND JSON SITE INDEX
# Assign pwd to curdir variable
curdir=$(pwd)
# Build Hugo. It's okay if there are dupes in the publishdir ("public") because we are just looking for the newest site-index.html anyway
hugo
cp ${curdir}/public/json/index.html ${curdir}/static/site-index.json
cd $curdir
# Cleans out Build Directory "Public" if it exists
if [ -d "public" ]; then
rm -rf public
fi
# "Image copy" = copies all images in "content" to /static/images/ before running the server. NOTE:
# 1. This requires additional client-side tweaks so that images still serve correctly to the browser.
# 2. This is based on the assumption that content md files reference images from relative images directory: eg, ![my alt text](images/my-image.jpg)
# Begin Image copy
find ${curdir}/content \( -iname '*.jpg' -o -iname '*.png' -o -iname '*.gif' \) -type f -exec cp -v -- {} ${curdir}/static/images/ \;
# End Image copy
# Open Google Chrome
open -a Google\ Chrome.app http://localhost:1313
hugo server
###6. Client-side <img src="*"
tweak if you keep all your images in the respective folder and include the image copy step in the above bash
script:
/*
This IIFE appends a '/' to all img[src] so that in-page images point to a single image repo at the root.
This reflects a GitHub-based authorship model where documentation authors are creating md files in individual GitHub directories and then dragging and dropping documentation images into an `images` subdirectory.
*/
(function() {
var allImages = document.querySelectorAll('img');
if (allImages.length > 0) {
for (var i = 0; i < allImages.length; i++) {
var originalSource = allImages[i].getAttribute('src');
var newSrc = '/' + originalSource;
allImages[i].setAttribute('src', newSrc);
}
}
})();
I will eventually be tweaking the above bash script as part of a Wercker auto-deployment. I really should just write a blog post for all of this…stay tuned.