Skip to content

Instantly share code, notes, and snippets.

@Arty2

Arty2/fixedsearch.js

Forked from cmod/hugofastsearch.md
Last active Mar 1, 2021
Embed
What would you like to do?
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
--------------------------------------------------------------*/
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>
@thedivtagguy

This comment has been minimized.

Copy link

@thedivtagguy thedivtagguy commented Jan 5, 2021

Hey, I tried implementing your fork on my website but for some reason it's redirecting me to DuckDuckGo?

What is the purpose of this line? https://gist.github.com/Arty2/8b0c43581013753438a3d35c15091a9f#file-search-html-L2

@Arty2

This comment has been minimized.

Copy link
Owner Author

@Arty2 Arty2 commented Jan 5, 2021

@thedivtagguy

This comment has been minimized.

Copy link

@thedivtagguy thedivtagguy commented Jan 5, 2021

You're right, for some reason fixedsearch.js is not loading. I've placed it in my themes//static/scripts folder, along with fuse.js, and as far as I can make out I've not changed anything in your code. How is it loading one and not the other?

https://github.com/thedivtagguy/archives/tree/search I've uploaded the files here, could you tell me what I'm doing wrong?

@Arty2

This comment has been minimized.

Copy link
Owner Author

@Arty2 Arty2 commented Jan 5, 2021

@thedivtagguy Oh I see where the issue is, the scripts should be placed at static/scripts/fixedsearch/. I have also updated the instructions to reflect that. Keep in mind that the static/ folder should be directly under the root folder and not inside themes/: Static Files

@thedivtagguy

This comment has been minimized.

Copy link

@thedivtagguy thedivtagguy commented Jan 5, 2021

Thank you, that fixes it! Didn't know about the static folder bit. Just another quick question, would adding more parameters to the index.json improve results? I have edited my index.json as follows:

 {{- $index = $index | append (dict "title" ( .Title | plainify ) "permalink" .Permalink "section" (i18n (.Section | title)) "tags" (apply .Params.tags "i18n" "." ) "categories" (apply .Params.categories "i18n" "." ) "description" (.Params.description | markdownify | htmlUnescape | plainify) "major" (.Params.major | markdownify | htmlUnescape | plainify) "tools" (.Params.tools | markdownify | htmlUnescape | plainify) "date" ($.Scratch.Get "date") ) -}}

Some of these are custom frontmatter that I have for my posts. I had hoped that by included description, I would be able to search those words too. However, when I search for terms that are obviously there, results don't come up.

@Arty2

This comment has been minimized.

Copy link
Owner Author

@Arty2 Arty2 commented Jan 5, 2021

@thedivtagguy It’s possible to modify it —as you already did— to include such custom fields in the JSON index, then you need to add these fields in the keys array in fixedsearch.js under line 183. If you also wish to have them appear in the UI, you can edit the output under line 219.

@Pirols

This comment has been minimized.

Copy link

@Pirols Pirols commented Feb 23, 2021

Ehi there!
Thank you for the gist. I'm working on a blog created using hugo and this theme.
I followed your gist as well as others' but didn't get any luck and can't still manage to have a functioning search mechanism with multilingual capabilities(eng and ita).

I have followed your instructions but, unfortunately, got the same issue @thedivtagguy did, i.e. when trying to search I get redirected to duckduckgo. In my case, both fixedsearch.js and fuse.js are already located into static/scripts/fixedsearch/ and no errors are reported on the console. The source code is here and I was wondering if you could be so kind to have a look to help me. In case you do, please notice that the search bar appears on the top right corner and is not very visible, I didn't change the css yet.

@Arty2

This comment has been minimized.

Copy link
Owner Author

@Arty2 Arty2 commented Feb 23, 2021

Hi @Pirols, if the script fails to run, then the search mechanism falls back to the duckduckgo. Could you share
what error appears in the search console?

Your theme appears to be calling the search partial twice, see line 52 of https://github.com/cookintrix/cookintrix.it/blob/3bbf855888f529651c2174414ad4b59e3942c3ca/layouts/_default/baseof.html#L52 which may be causing the issue.

@Pirols

This comment has been minimized.

Copy link

@Pirols Pirols commented Feb 23, 2021

Hi @Pirols, if the script fails to run, then the search mechanism falls back to the duckduckgo. Could you share
what error appears in the search console?

Your theme appears to be calling the search partial twice, see line 52 of https://github.com/cookintrix/cookintrix.it/blob/3bbf855888f529651c2174414ad4b59e3942c3ca/layouts/_default/baseof.html#L52 which may be causing the issue.

Thank you so much! Removing the double call fixed the issue.
As for the errors, the console only outputs those regarding ddg which I don't think would be relevant anyway.

@Arty2

This comment has been minimized.

Copy link
Owner Author

@Arty2 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

This comment has been minimized.

Copy link

@Pirols 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!

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