Skip to content

Instantly share code, notes, and snippets.

@cmod
Forked from eddiewebb/readme.md
Last active March 1, 2025 20:50
Show Gist options
  • Save cmod/5410eae147e4318164258742dd053993 to your computer and use it in GitHub Desktop.
Save cmod/5410eae147e4318164258742dd053993 to your computer and use it in GitHub Desktop.
Fast, instant client side search for Hugo static site generator

Super fast, keyboard-optimized, client side Hugo search

This is a fork of and builds upon the work of Eddie Webb's search and Matthew Daly's search explorations.

It's built for the Hugo static site generator, but could be adopted to function with any json index.

To see it in action, go to craigmod.com and press CMD-/ and start typing.

Fast Search



Why client side, why fast?

I believe Fast Software is the Best Software and wanted keyboard-based, super fast search for my homepage / online collection of essays. This method was highly inspired by Sublime Text's CMD-P/CMD-shift-P method of opening files / using functions.

The core precepts of this exploration were:

  • Minimal / zero external dependencies (no jQuery)
  • Smallest possible size added to each page
  • json index only delivered when needed (further minimizing overall impact on page speed / user experience)
  • Keyboard friendly, instant navigation (ala Alfred / macOS Spotlight)

As Eddie Webb points out, this method has the additional benefits of:

  • No NPM, grunt, etc
  • No additional build time steps, just hugo as you would normally
  • Easy to swap out choice of client side search tools, anything that can use a json index

Example: craigmod.com

This search is live on my site, craigmod.com on every page. How to use:

  • Press CMD-/ to invoke search
  • start typing
  • use tab / arrow keys to select result
  • press enter to navigate to that page

Setup

  1. Add index.json file to layouts/_default
  2. Add JSON as additional output format in config.toml
  3. Add search.js to static/js
  4. Add searchbox html to bottom of layouts/_default/baseof.html
  5. Add css styles to your site's main css file or top of baseof.html
  6. hugo
  7. Visit localhost:1313/
  8. Press CMD-/ to invoke search

You can check the json index by visiting localhost:1313/index.json

Files

layouts/_default/baseof.html addition

Add this block to the bottom of your baseof.html file, just before the footer

 <div id="fastSearch">
   <input id="searchInput" tabindex="0">
   <ul id="searchResults">
   </ul>
 </div>
 <script src="/js/fastsearch.js"></script>

static/js/fastsearch.js

This "makes" the search engine based on the index.json file, and wires up all keyboard handling

/*  
====================================================================

FAST SEARCH — 
https://gist.github.com/cmod/5410eae147e4318164258742dd053993
Updated to work with fuse 7 (Jan 2025)
Updated Feb 2025 — no more fuse dependency, more modern js, 
   proper config items, ability to easily modify shortcut, 
   general speed improvements

==================================================================== 
*/
// Configuration
const DEFAULT_CONFIG = {
  shortcuts: {
    open: {                    // Shortcut to open/close search
      key: '/',                // The key to trigger the shortcut
      metaKey: true,          // Requires Cmd/Win key
      altKey: false,          // Requires Alt key
      ctrlKey: false,         // Requires Ctrl key
      shiftKey: false         // Requires Shift key
    }
  },
  search: {
    minChars: 2,              // Minimum characters before searching
    maxResults: 5,            // Maximum number of results to show
    fields: {                 // Fields to search through
      title: true,            // Allow searching in title
      description: true,      // Allow searching in description
      section: true           // Allow searching in section
    }
  }
};

// Function to initialize search with custom config
function initSearch(userConfig = {}) {
  // Deep merge of default config with user config
  const CONFIG = mergeConfigs(DEFAULT_CONFIG, userConfig);
  
  // Cache DOM elements
  const fastSearch = document.getElementById('fastSearch');
  const searchInput = document.getElementById('searchInput');
  const searchResults = document.getElementById('searchResults');

  let searchIndex = null;
  let searchVisible = false;
  let resultsAvailable = false;
  let firstRun = true;

  // Load the search index
  async function loadSearchIndex() {
    try {
      const response = await fetch('/index.json');
      if (!response.ok) throw new Error('Failed to load search index');
      const data = await response.json();
      
      searchIndex = data.map(item => ({
        ...item,
        searchableTitle: item.title?.toLowerCase() || '',
        searchableDesc: item.desc?.toLowerCase() || '',
        searchableSection: item.section?.toLowerCase() || ''
      }));
      
      if (searchInput.value) {
        performSearch(searchInput.value);
      }
    } catch (error) {
      console.error('Error loading search index:', error);
      searchResults.innerHTML = '<li class="search-message">Error loading search index...</li>';
    }
  }

  // Simple fuzzy match for single words
  function simpleFuzzyMatch(text, term) {
    if (text.includes(term)) return true;
    if (term.length < 3) return false;

    let matches = 0;
    let lastMatchIndex = -1;

    for (let i = 0; i < term.length; i++) {
      const found = text.indexOf(term[i], lastMatchIndex + 1);
      if (found > -1) {
        matches++;
        lastMatchIndex = found;
      }
    }

    return matches === term.length;
  }

  // Check if keyboard event matches shortcut config
  function matchesShortcut(event, shortcutConfig) {
    return event.key === shortcutConfig.key &&
           event.metaKey === shortcutConfig.metaKey &&
           event.altKey === shortcutConfig.altKey &&
           event.ctrlKey === shortcutConfig.ctrlKey &&
           event.shiftKey === shortcutConfig.shiftKey;
  }

  // Keyboard shortcuts
  document.addEventListener('keydown', (event) => {
    // Check for configured search shortcut
    if (matchesShortcut(event, CONFIG.shortcuts.open)) {
      event.preventDefault();
      searchVisible = !searchVisible;
      fastSearch.style.visibility = searchVisible ? 'visible' : 'hidden';
      
      if (searchVisible) {
        if (firstRun) {
          loadSearchIndex();
          firstRun = false;
        }
        searchInput.focus();
        searchInput.value = '';
      } else {
        searchInput.blur();
        searchResults.innerHTML = '';
      }
    }

    // ESC to close search
    if (event.key === 'Escape' && searchVisible) {
      fastSearch.style.visibility = 'hidden';
      searchInput.blur();
      searchInput.value = '';
      searchResults.innerHTML = '';
      searchVisible = false;
    }
    
    // Enter to select first result
    if (event.key === 'Enter' && searchVisible) {
      const firstResult = searchResults.querySelector('a');
      if (firstResult) {
        event.preventDefault();
        window.location.href = firstResult.href;
      }
    }

    // Arrow navigation
    if (searchVisible && resultsAvailable) {
      const links = Array.from(searchResults.getElementsByTagName('a'));
      if (!links.length) return;

      const first = links[0];
      const last = links[links.length - 1];
      const active = document.activeElement;
      
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        if (active === searchInput) {
          first.focus();
        } else if (active.tagName === 'A') {
          const currentIndex = links.indexOf(active);
          if (currentIndex !== -1 && currentIndex < links.length - 1) {
            links[currentIndex + 1].focus();
          }
        }
      }
      
      if (event.key === 'ArrowUp') {
        event.preventDefault();
        if (active === first) {
          searchInput.focus();
        } else if (active.tagName === 'A') {
          const currentIndex = links.indexOf(active);
          if (currentIndex > 0) {
            links[currentIndex - 1].focus();
          } else {
            searchInput.focus();
          }
        }
      }
    }
  });

  function performSearch(term) {
    term = term.toLowerCase().trim();
    
    if (!term || !searchIndex) {
      searchResults.innerHTML = '';
      resultsAvailable = false;
      return;
    }

    if (term.length < CONFIG.search.minChars) {
      searchResults.innerHTML = '<li class="search-message">Please enter at least 2 characters...</li>';
      resultsAvailable = false;
      return;
    }

    // Split search into terms
    const searchTerms = term.split(/\s+/).filter(t => t.length > 0);
    
    // Search with scoring
    const results = searchIndex
      .map(item => {
        let score = 0;
        const matchesAllTerms = searchTerms.every(term => {
          let matched = false;
          
          // Title matches (weighted higher)
          if (CONFIG.search.fields.title) {
            if (item.searchableTitle.startsWith(term)) {
              score += 3;  // Highest score for prefix matches in title
              matched = true;
            } else if (simpleFuzzyMatch(item.searchableTitle, term)) {
              score += 2;  // Good score for fuzzy matches in title
              matched = true;
            }
          }
          
          // Other field matches
          if (!matched) {
            if (CONFIG.search.fields.description && item.searchableDesc.includes(term)) {
              score += 0.5;  // Lower score for description matches
              matched = true;
            }
            if (CONFIG.search.fields.section && item.searchableSection.includes(term)) {
              score += 0.5;  // Lower score for section matches
              matched = true;
            }
          }
          
          return matched;
        });

        return {
          item,
          score: matchesAllTerms ? score : 0
        };
      })
      .filter(result => result.score > 0)
      .sort((a, b) => b.score - a.score)
      .slice(0, CONFIG.search.maxResults)
      .map(result => result.item);

    resultsAvailable = results.length > 0;
    
    if (!resultsAvailable) {
      searchResults.innerHTML = '<li class="search-message">No matching results found...</li>';
      return;
    }

    const searchItems = results.map(item => `
      <li>
        <a href="${escapeHtml(item.permalink)}" tabindex="0">
          <span class="title">${escapeHtml(item.title)}</span><br />
          <span class="sc">${escapeHtml(item.section)}</span> — 
          ${escapeHtml(item.date)}
          <em>${escapeHtml(item.desc)}</em>
        </a>
      </li>
    `).join('');
    
    searchResults.innerHTML = searchItems;
  }

  searchInput.addEventListener('input', function() {
    if (!searchIndex && !firstRun) {
      searchResults.innerHTML = '<li class="search-message">Loading search index...</li>';
      return;
    }
    performSearch(this.value);
  });

  // Add minimal styles
  const style = document.createElement('style');
  style.textContent = `
    .search-message {
      padding: 8px;
      color: #666;
      font-style: italic;
    }

    #searchResults li {
      animation: fadeSlideIn 0.2s ease-out;
      animation-fill-mode: both;
    }

    #searchResults li:nth-child(1) { animation-delay: 0.0s; }
    #searchResults li:nth-child(2) { animation-delay: 0.02s; }
    #searchResults li:nth-child(3) { animation-delay: 0.04s; }
    #searchResults li:nth-child(4) { animation-delay: 0.06s; }
    #searchResults li:nth-child(5) { animation-delay: 0.08s; }

    @keyframes fadeSlideIn {
      from {
        opacity: 0;
        transform: translateY(-10px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }
  `;
  document.head.appendChild(style);
}

// Helper function to deep merge configs
function mergeConfigs(defaultConfig, userConfig) {
  const merged = { ...defaultConfig };
  
  for (const [key, value] of Object.entries(userConfig)) {
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      merged[key] = mergeConfigs(defaultConfig[key] || {}, value);
    } else {
      merged[key] = value;
    }
  }
  
  return merged;
}

// Basic HTML escaping for security
function escapeHtml(unsafe) {
  if (!unsafe) return '';
  return unsafe
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

// Initialize with default config
initSearch();

CSS Styling

#fastSearch { 
  visibility: hidden;
  position: absolute;
  right: 0px;
  top: 0px;
  display: inline-block;
  width: 300px;
}      

#fastSearch input { 
  padding: 4px 10px;
  width: 100%;
  height: 31px;
  font-size: 1.6em;
  color: #aaa;
  font-weight: bold;
  background-color: #000;
  border-radius: 3px 3px 0px 0px;
  border: none;
  outline: none;
  text-align: left;
  display: inline-block;
}

#searchResults li { 
  list-style: none; 
  margin-left: 0em;
  background-color: #333; 
  border-bottom: 1px dotted #000;
}
  #searchResults li .title { font-size: 1.1em; margin-bottom: 10px; display: inline-block;}

#searchResults { visibility: inherit; display: inline-block; width: 320px; }
#searchResults a { text-decoration: none !important; padding: 10px; display: inline-block; }
  #searchResults a:hover, a:focus { outline: 0; background-color: #666; color: #fff; }
  

layouts/_default/index.json

Hugo already builds indexes of all pages, we can cherry-pick which aspects should be searchable. The result is a newly created JSON index at /index.json

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

Config.toml

Add this snippet to your config file to instruct Hugo to create the index file in JSON format. (RSS and HTML are default outputs, what's important is to add JSON.

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

Alternately if using a custom _index.md for home page, you can just add the output formats to front matter.

outputs:
- html
- rss
- json

See https://gohugo.io/templates/output-formats#output-formats-for-pages

License

The above cobbled together bits are provided as is under the so-called MIT License. Do whatever ya want with it all.

Copyright 2020 Craig Mod

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.

@kepler-69c
Copy link

The events in the keyUp listener are deprecated.

Use event.key instead of event.keyCode, e.g. (line 16)

if (event.metaKey && event.key === '/') {
...

@cmod
Copy link
Author

cmod commented Aug 24, 2023

The events in the keyUp listener are deprecated.

Use event.key instead of event.keyCode, e.g. (line 16)

if (event.metaKey && event.key === '/') {
...

Thanks, updated!

@dsryu0822
Copy link

Is there any successed multilingual model? Maybe it fails search, even through index.json is well constructed.

@ZMYaro
Copy link

ZMYaro commented Nov 18, 2024

Very late contributing to this, but I would like to suggest Alt+/ as the shortcut on non-Mac platforms. It is already used in other applications (most notably the Google Workspace suite), it aligns with the Windows standard of Alt+ shortcuts to open menus, and as far as I can tell, it doesn't conflict with other browser or system shortcuts.

Regardless, using event.metaKey shortcuts in a web page (or even native app) on Chrome OS or Windows is generally bad practice since 🔍+ shortcuts (CrOS) or + shortcuts (Windows) are expected to be system shortcuts.

Because of Apple's different keyboard shortcut conventions, I have often done things like:

const isApple = (navigator.userAgent.indexOf('Mac') !== -1);
function isPlatformAltOrCmdKey(ev) {
	// On MacOS and iOS, check Cmd; on other platforms, check Alt.
	return ((!isApple && ev.altKey) || (isApple && ev.metaKey));
}

if (isPlatformAltOrCmdKey(event) && event.key === '/') {
	

@TajangSec
Copy link

Can it be used explicitly? I have designed a nice search box, and I want it to automatically search and display content as I type characters.

@cmod
Copy link
Author

cmod commented Jan 17, 2025

Updated to work with latest Fuse (7.0)

@cmod
Copy link
Author

cmod commented Feb 16, 2025

updated to be more performant and allow easy changing of hotkey instantiation

@cmod
Copy link
Author

cmod commented Feb 20, 2025

now no longer dependent on fuse, added Enter to select and go to first result url

@matthieugd
Copy link

@cmod in the instructions, the index.json template doesn't fit the variable names used in the JS file. I tweaked it to

  • have the content as the desc (searchable)
  • a new summary property to be displayed in lieu of the desc property
  • added the date variable missing in the index.json but used in the result template
  • added the section variable missing

{{- $.Scratch.Add "index" slice -}} {{- range .Site.RegularPages -}} {{- $.Scratch.Add "index" (dict "date" (.PublishDate.Format "01-12-2006") "title" .Title "tags" .Params.tags "section" (index .Params.categories 0) "desc" .Plain "summary" (printf "%s..." (substr (.Summary | plainify) 0 90)) "permalink" .Permalink) -}} {{- end -}} {{- $.Scratch.Get "index" | jsonify -}}

Line 251 of the template, I use item.summary to display the first X characters

Thanks for this great tip for Hugo websites !

@arzoriac
Copy link

arzoriac commented Mar 1, 2025

Cmd + / doesn't work on Safari (macOS) since it is the hotkey to show/hide the status bar.

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