Skip to content

Instantly share code, notes, and snippets.

@Arty2
Forked from cmod/hugofastsearch.md
Last active December 14, 2023 17:10
Show Gist options
  • Star 25 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save Arty2/8b0c43581013753438a3d35c15091a9f to your computer and use it in GitHub Desktop.
Save Arty2/8b0c43581013753438a3d35c15091a9f to your computer and use it in GitHub Desktop.
Super fast, client side search for Hugo.io with Fusejs.io

/fixedsearch

Super fast, client side search for Hugo.io with Fusejs.io

This is a fork of and builds upon the work of Craig Mod’s fastSearch, Eddie Webb’s search and Matthew Daly’s search explorations.

It’s built for the Hugo static site generator with the /onion theme in mind, but ought to be easy to customize and for other themes and generators. Makes use of the lightweight fuzzy-search library Fuse.js but you could swap-in any similar library.

How to use

See heracl.es for a live preview: hit Ctrl + / or click on the magnifying glass icon and start typing. With its default styling, fixedsearch appears as a magnifying glass ( ⌕ ) at the top right of your pages. When in focus, a fixed interface appears at the right.

  • Press Ctrl + / to invoke search or click on the magnifying glass icon.
  • Start typing and see results as you type.
  • Use Enter once, or Tab or arrow keys to select result.
  • Press Enter to navigate to the selected page.
  • You may exit the search interface with the Esc key, or taking focus away from it.
  • You may return from the results to the search box with the Backspace key.

Why?

There was no lean plug & play solution for a client-side search in Hugo to complement the /onion theme’s design principles:

  • Plug & Play. Should work with zero proprietary params in configuration file.
  • Progressive enhancement with single-purpose vanilla JavaScript libraries. As few as possible.
  • Graceful degradation for older browsers, but no feature parity.
  • No pre-proccessors. No frameworks. No external depedencies.
  • Multilingual first. Greek translations included.
  • Mobile first. Two fluid responsive breakpoints.
  • Respect user’s system preferences: default automatic light / dark theme.

However there was considerable work in the area. The core precepts for the scripts fixedsearch is based on, are futher explained by both Craig Mod and Eddie Webb. Kaushal Modi’s is also worth a mention, but is lacking in terms of usability.

Beyond code refactoriung, this is what the modifications in fixedsearch do:

  • Fix some minor issues that prevent Craig Mod’s implementation from working as-is.
  • Put everything in local JavaScript scope, or at least it’s supposed to.
  • Only load fuse.js when needed. This will not work in older browsers.
  • When JavaScript is disabled, fallback to a search form for DuckDuckGo instead.
  • Add multilingual (i18n) support.
  • Controversial: Make use of the <style> tag in the <body> rather than <head> which is syntactically incorrect, but works in all modern browsers.
  • User Interface improvements to make usable with touch screens.
  • Loop through results with the down key.
  • Use .Params.summary rather than .Params.description. See what’s the difference
  • Futher add to the JSON index: + Post date, supporting site.Params.date_format configuration. + Tags, Categories, Summary + If you also wish to add the full contents (not suggested) to this index, then put in "contents" .Plain in the dict structure.
  • The quick-launch shortcut has been changed to Ctrl + / since then Cmd key clashes with Firefox’s Quick Find shortcut.
  • New keyboard shortcuts: hit Enter to go to the first result, or Backspace to return to the search input.

Areas of improvement

  • Turn this Gist into a proper repository if there’s interest.
  • JavaScript code cleanup. Currently a mix of different coding styles.
  • CSS code cleanup; add reset styles.
  • Better keyboard-only support (focusin, focusout).
  • Separate styles into its own file, include and minimize inline.

Setup

  1. Add search.html file to layouts/partials/
  2. Add index.json file to layouts/_default/
  3. Add JSON as additional output format in config.yaml or config.toml
  4. Add fixedsearch.js and fuse.js (downloaded from fusejs.io or directly from jsdelivr) to static/scripts/fixedsearch/
  5. Add the search.html partial by iincluding {{ partialCached "search" . }} in your theme’s layouts/_default/baseof.html before the </body> tag.
  6. Preview or deploy as usual. You can inspect the JSON index by visiting localhost:1313/index.json

Example Hugo configuration

Modify the approproate settings in your config.yaml or config.toml file to instruct Hugo to create the index file in JSON format. RSS and HTML are default output formats, what’s important is to add JSON.

outputs:
  home:
    - html
    - rss
    - json
  page:
    - html
[outputs]
  home = ["HTML", "RSS", "JSON"]
// static/scripts/fixedsearch/fixedsearch.js
/*--------------------------------------------------------------
fixedsearch — Super fast, client side search for Hugo.io with Fusejs.io
based on https://gist.github.com/cmod/5410eae147e4318164258742dd053993
--------------------------------------------------------------*/
if (typeof variable !== 'undefined') {
console.log('fixedsearch.js already loaded');
} else {
fixedsearch = function(){
var search_form = document.getElementById('search-form'); // search form
var search_input = document.getElementById('search-input'); // input box for search
var search_submit = document.getElementById('search-submit'); // form submit button
var search_results = document.getElementById('search-results'); // targets the <ul>
var fuse; // holds our search engine
var search__focus = false; // check to true to make visible by default
var results_available = false; // did we get any search results?
var first_run = true; // allow us to delay loading json data unless search activated
var first = search_results.firstChild; // first child of search list
var last = search_results.lastChild; // last child of search list
search_form.classList.remove('noscript'); // JavaScript is active
search_form.setAttribute('data-focus', search__focus);
/*--------------------------------------------------------------
The main keyboard event listener running the show
--------------------------------------------------------------*/
document.addEventListener('keydown', function(e) {
// console.log(event); // DEBUG
// Ctrl + / to show or hide Search
// if (event.metaKey && event.which === 191) {
if (event.ctrlKey && event.which === 191) {
search_toggle_focus(e); // toggle visibility of search box
}
});
/*--------------------------------------------------------------
The main keyboard event listener running the show
--------------------------------------------------------------*/
search_form.addEventListener('keydown', function(e) {
// Allow ESC (27) to close search box
if (e.keyCode == 27) {
search__focus = true; // make sure toggle removes focus
search_toggle_focus(e);
}
// DOWN (40) arrow
if (e.keyCode == 40) {
if (results_available) {
e.preventDefault(); // stop window from scrolling
if ( document.activeElement == search_input) { first.focus(); } // if the currently focused element is the main input --> focus the first <li>
else if ( document.activeElement == last ) { first.focus(); } // if we're at the bottom, loop to the start
// else if ( document.activeElement == last ) { last.focus(); } // if we're at the bottom, stay there
else { document.activeElement.parentElement.nextSibling.firstElementChild.focus(); } // otherwise select the next search result
}
}
// UP (38) arrow
if (e.keyCode == 38) {
if (results_available) {
e.preventDefault(); // stop window from scrolling
if ( document.activeElement == search_input) { search_input.focus(); } // If we're in the input box, do nothing
else if ( document.activeElement == first) { search_input.focus(); } // If we're at the first item, go to input box
else { document.activeElement.parentElement.previousSibling.firstElementChild.focus(); } // Otherwise, select the search result above the current active one
}
}
// Use Enter (13) to move to the first result
if (e.keyCode == 13) {
if (results_available && document.activeElement == search_input) {
e.preventDefault(); // stop form from being submitted
first.focus();
}
}
// Use Backspace (8) to switch back to the search input
if (e.keyCode == 8) {
if (document.activeElement != search_input) {
e.preventDefault(); // stop browser from going back in history
search_input.focus();
}
}
});
/*--------------------------------------------------------------
Load our json data and builds fuse.js search index
--------------------------------------------------------------*/
search_form.addEventListener('focusin', function(e) {
search_init(); // try to load the search index
});
/*--------------------------------------------------------------
Make submit button toggle focus
--------------------------------------------------------------*/
search_form.addEventListener('submit', function(e) {
search_toggle_focus(e);
e.preventDefault();
return false;
});
/*--------------------------------------------------------------
Remove focus on blur
--------------------------------------------------------------*/
search_form.addEventListener('focusout', function(e) {
if (e.relatedTarget === null) {
search_toggle_focus(e);
}
else if (e.relatedTarget.type === 'submit') {
e.stopPropagation();
}
});
/*--------------------------------------------------------------
Toggle focus UI of form
--------------------------------------------------------------*/
function search_toggle_focus(e) {
// console.log(e); // DEBUG
// order of operations is very important to keep focus where it should stay
if (!search__focus) {
search_submit.value = '⨯';
search_form.setAttribute('data-focus', true);
search_input.focus(); // move focus to search box
search__focus = true;
}
else {
search_submit.value = '⌕';
search_form.setAttribute('data-focus', false);
document.activeElement.blur(); // remove focus from search box
search__focus = false;
}
}
/*--------------------------------------------------------------
Fetch some json without jquery
--------------------------------------------------------------*/
function fetch_JSON(path, callback) {
var httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function() {
if (httpRequest.readyState === 4) {
if (httpRequest.status === 200) {
var data = JSON.parse(httpRequest.responseText);
if (callback) callback(data);
}
}
};
httpRequest.open('GET', path);
httpRequest.send();
}
/*--------------------------------------------------------------
Load script
based on https://stackoverflow.com/a/55451823
--------------------------------------------------------------*/
function load_script(url) {
return new Promise(function(resolve, reject) {
let script = document.createElement("script");
script.onerror = reject;
script.onload = resolve;
if (document.currentScript) {
document.currentScript.parentNode.insertBefore(script, document.currentScript);
}
else {
document.head.appendChild(script)
}
script.src = url;
});
}
/*--------------------------------------------------------------
Load our search index, only executed once
on first call of search box (Ctrl + /)
--------------------------------------------------------------*/
function search_init() {
if (first_run) {
load_script(window.location.origin + '/scripts/fixedsearch/fuse.js').then(() => {
search_input.value = ""; // reset default value
first_run = false; // let's never do this again
fetch_JSON(search_form.getAttribute('data-language-prefix') + '/index.json', function(data){
var options = { // fuse.js options; check fuse.js website for details
shouldSort: true,
location: 0,
distance: 100,
threshold: 0.4,
minMatchCharLength: 2,
keys: [
'permalink',
'title',
'date',
'summary',
'section',
'categories',
'tags'
]
};
fuse = new Fuse(data, options); // build the index from the json file
search_input.addEventListener('keyup', function(e) { // execute search as each character is typed
search_exec(this.value);
});
// console.log("index.json loaded"); // DEBUG
});
}).catch((error) => { console.log('fixedsearch failed to load: ' + error); });
}
}
/*--------------------------------------------------------------
Using the index we loaded on Ctrl + /, run
a search query (for "term") every time a letter is typed
in the search box
--------------------------------------------------------------*/
function search_exec(term) {
let results = fuse.search(term); // the actual query being run using fuse.js
let search_items = ''; // our results bucket
if (results.length === 0) { // no results based on what was typed into the input box
results_available = false;
search_items = '';
} else { // build our html
for (let item in results.slice(0,5)) { // only show first 5 results
search_items = search_items +
`<li><a href="${results[item].item.permalink}" tabindex="0">
<span class="title">${results[item].item.title}</span>
<span class="date">${results[item].item.date}</span>
<span class="summary">${results[item].item.summary}</span>
<span class="section">${results[item].item.section}</span>
<span class="categories">${results[item].item.categories.join(', ')}</span>
<span class="tags">${results[item].item.tags.join(', ')}</span>
</a></li>`;
}
results_available = true;
}
search_results.innerHTML = search_items;
if (results.length > 0) {
first = search_results.firstChild.firstElementChild; // first result container — used for checking against keyboard up/down location
last = search_results.lastChild.firstElementChild; // last result container — used for checking against keyboard up/down location
}
}
}();
}
{{/* layouts/_default/index.json */}}
{{- $index := slice -}}
{{- range where .Site.RegularPages.ByDate.Reverse "Type" "not in" (slice "page" "json") -}}
{{ if .Params.dateCreated }}
{{ $.Scratch.Set "date" (.Params.dateCreated) }}
{{ else }}
{{- if isset site.Params "date_format" -}}
{{- $.Scratch.Set "date" (.Date.Format site.Params.date_format) -}}
{{- else -}}
{{- $.Scratch.Set "date" (.Date.Format "2006-01-02") -}}
{{- end -}}
{{ end }}
{{- $index = $index | append (dict "title" ( .Title | plainify ) "permalink" .Permalink "section" (i18n (.Section | title)) "tags" (apply .Params.tags "i18n" "." ) "categories" (apply .Params.categories "i18n" "." ) "summary" (.Params.summary | markdownify | htmlUnescape | plainify) "date" ($.Scratch.Get "date") ) -}}
{{- end -}}
{{- $index | jsonify -}}

© 2020 Heracles Papatheodorou a.k.a @Arty2

Based on the work of Craig Mod, Eddie Webb and Matthew Daly.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

<!-- layouts/partials/search.html -->
<form id="search-form" class="noscript" method="GET" action="https://duckduckgo.com/" data-language-prefix="{{ site.LanguagePrefix }}">
<div class="search-bar">
<input id="search-input" class="search--display" name="q" tabindex="0" autocomplete="off" {{ with site.BaseURL }}value=" site:{{ . }}"{{ end }} placeholder="{{ i18n "search" }}">
<input id="search-submit" class="icon" type="submit" value="⌕">
</div>
<ul id="search-results" class="search--display"></ul>
</form>
<!-- <script src="{{ "/scripts/fuse.js" | relURL }}"></script> -->
<script src="{{ "/scripts/fixedsearch/fixedsearch.js" | relURL }}"></script>
<style>
#search-form {
display: block;
position: fixed;
top: 0;
right: 0;
margin: 0;
padding: 0;
min-width: var(--icon-size, 1.2rem);
max-width: 15rem;
max-height: 100vh;
z-index: 1000;
}
#search-form.search--display,
#search-form[data-focus='false'] .search--display {
display: none;
}
#search-form[data-focus='true'] .search--display {
display: block;
}
#search-form .search-bar {
display: flex;
height: 1.4rem;
align-items: center;
}
#search-form .search-bar {
background: rgb(var(--page-color, '240,240,240'));
color: rgb(var(--text-color, '3,3,3'));
}
#search-form[data-focus='true'] .search-bar {
border: 2.5px solid rgb(var(--accent-color, '0,0,255'));
}
#search-form[data-focus='true'] .search-bar:not(:focus-within) {
background: rgb(var(--text-color, '3,3,3'));
color: rgb(var(--page-color, '240,240,240'));
border-color: rgb(var(--text-color, '3,3,3'));
}
#search-input {
display: block;
padding: 0.3rem;
width: 15rem;
max-width: 100vw;
height: 100%;
background: inherit;
border: none;
color: inherit;
outline: none;
text-align: left;
font-size: var(--s-2, 0.8rem);
}
#search-submit {
display: block;
margin: 0 var(--icon-pad, 0.1rem) 0 0;
padding: 0;
width: var(--icon-size, 1.2rem);
height: var(--icon-size, 1.2rem);
line-height: var(--icon-size, 1.2rem);
background: inherit;
color: inherit;
}
#search-submit:hover,
#search-submit:focus {
color: rgb(var(--accent-color, '0,0,255'));
}
#search-results {
display: block;
flex-grow: 2;
margin: 0.5px 0 0 0;
padding: 0;
max-height: calc(100vh - 1.4rem - 0.5px);
width: 100%;
overflow-x: hidden;
overflow-y: auto;
backdrop-filter: blur(var(--page-color-blur, 2px));
border: 2.5px solid;
color: rgb(var(--text-color, '3,3,3'));
font-size: var(--s-2, 0.8rem);
scrollbar-color: rgb(var(--text-color, '3,3,3')) rgb(var(--page-color, '240,240,240')) !important;
scrollbar-width: thin !important;
}
#search-results:empty {
display: none;
opacity: 0;
}
#search-results li {
margin: 0;
width: 100%;
background-color: rgba(var(--page-color, '240,240,240'), var(--page-color-transparency, 0.89));
border-top: 0.5px dashed;
list-style: none;
}
#search-results li:first-child {
border: none;
}
#search-results a {
display: block;
padding: 0.3rem;
display: block;
text-decoration: none;
}
#search-results a:hover,
#search-results a:focus {
background: rgb(var(--text-color, '3,3,3'));
color: rgb(var(--page-color, '240,240,240'));
outline: 0;
}
#search-results a:active {
background: rgb(var(--accent-color, '0,0,255'));
color: rgb(var(--page-color, '240,240,240'));
}
#search-results li span:empty {
display: none;
}
#search-results li span:not(:last-child)::after {
content: ' – ';
}
#search-results li .title {
display: block;
margin-bottom: calc(var(--line-height, 1.4em)/3);
}
#search-results li .title::after {
display: none;
}
</style>
@Arty2
Copy link
Author

Arty2 commented Feb 23, 2021

I am glad that worked! Perhaps I should add a check to make sure the script doesn't try to load twice.

@Pirols
Copy link

Pirols commented Feb 24, 2021

I think that adding such a check would definitely help people like me, just trying to build a simple website starting from some theme, without much experience in the field.

Again, thank you so much! Keep it up!

@danisztls
Copy link

danisztls commented Jun 30, 2021

You can use template strings to avoid successive concatenations. Mine is this:

items +=`<li><a href="${results[i].item.href}">${results[i].item.title}</a></li>`

@Arty2
Copy link
Author

Arty2 commented Oct 3, 2021

The loading script now checks if it's already loaded.
@danisztls much appreciated, incorporated your suggestion and it’s now more readable.

@danisztls
Copy link

@Arty2 Thanks for this Gist. I used it as a source of ideas when writing a similar solution.
This is my contribution: lite-search. But notice that instructions are a bit poor and there's room for making it more reusable. 😄

@RoneoOrg
Copy link

Thanks a lot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment