Skip to content

Instantly share code, notes, and snippets.

@arielsalminen
Last active April 6, 2022 14:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save arielsalminen/575a783b42b8985c1641a314163fa9c0 to your computer and use it in GitHub Desktop.
Save arielsalminen/575a783b42b8985c1641a314163fa9c0 to your computer and use it in GitHub Desktop.
Demo of how we create a JSON based search index and search functionality for Nord Design System’s Eleventy based documentation at https://nordhealth.design/
/* eslint-disable no-undef */
;(function (window, document) {
"use strict"
/**
* The Nord Documentation object
*
* @constructor
*/
function NordDocs() {
this.supportsPassive = false
}
NordDocs.prototype = {
constructor: NordDocs,
/**
* Intializes the instance
*
* @function
*/
init: function () {
var self = this
// feature test for browser support
if (
(!window.history.pushState ||
!window.requestAnimationFrame ||
!window.addEventListener ||
!document.querySelectorAll,
!("classList" in document.documentElement))
) {
// Stop here if needed features aren’t supported
return
}
// Test via a getter in the options object to see if the passive property is accessed
try {
var opts = Object.defineProperty({}, "passive", {
get: function () {
self.supportsPassive = true
},
})
window.addEventListener("testPassive", null, opts)
window.removeEventListener("testPassive", null, opts)
// eslint-disable-next-line no-empty
} catch (e) {}
document.addEventListener(
"DOMContentLoaded",
function () {
self.load()
},
false
)
},
/**
* Handles load event
*
* @function
*/
load: function () {
// Reset global variables
this.searchIndex = null
this.searchTimer = null
// Find elements
this.resultsUI = document.querySelector(".n-search-results")
this.searchInput = document.getElementById("search")
this.searchClose = document.querySelector(".n-close")
// Initialize search
this.initSearch()
},
/**
* Clear the current results
*
* @function
*/
clearSearchResults: function () {
this.resultsUI.scrollTop = 0
while (this.resultsUI.firstChild) {
this.resultsUI.removeChild(this.resultsUI.firstChild)
}
},
/**
* Get up to date data for search
*
* @function
*/
getSearchData: function () {
var self = this
if (self.searchIndex === null) {
fetch("/search.json")
.then(function (response) {
return response.json()
})
.then(function (response) {
self.searchIndex = response.search
})
.catch(function (error) {
console.log("No search index file found")
})
}
},
/**
* Find and display
*
* @function
* @param {str} The search string to find
*/
findSearchResult: function (str) {
str = str.toLowerCase()
var self = this
// look for matches in the JSON
var results = []
for (var term in self.searchIndex) {
var foundTitle = self.searchIndex[term].readabletitle.indexOf(str)
var foundKeywords = self.searchIndex[term].keywords.indexOf(str)
if (foundTitle !== -1) {
results.unshift(self.searchIndex[term])
} else if (foundKeywords !== -1) {
results.push(self.searchIndex[term])
}
}
// build and insert the new result entries
this.clearSearchResults()
if (results.length) {
document.body.scrollTop = 0
document.documentElement.scrollTop = 0
var helpItem = document.createElement("li")
var helpText =
"<div class='n-search-help'>↑↓&nbsp; to navigate <i>|</i> Enter to select <i>|</i> Esc to dismiss</div>"
helpItem.className = "skip"
helpItem.innerHTML = helpText
self.resultsUI.appendChild(helpItem)
for (var item in results) {
var listItem = document.createElement("li")
var link = document.createElement("a")
var title = document.createElement("h2")
var text = document.createElement("p")
title.textContent = results[item].title
if (results[item].text.length <= 160) {
text.textContent = results[item].text
} else {
text.textContent = results[item].text.substring(0, 160) + "…"
}
link.setAttribute("href", results[item].url)
link.appendChild(title)
link.appendChild(text)
listItem.appendChild(link)
listItem.setAttribute("tabindex", "1")
self.resultsUI.appendChild(listItem)
}
} else {
self.resultsUI.innerHTML =
"<li><span><h2>No results for “" +
str +
"”.</h2><p>Search tips: some search terms require exact match. Try typing the entire search term, or use a&nbsp;different word or phrase.</p></span></li>"
}
},
/**
* Browse search results up or down
*
* @function
* @param {activeEl} The currently active element in results
* @param {next} Determine whether to browse forward or backward
*/
browseSearchResults: function (activeEl, next, fromBeginning) {
var el
if (!activeEl) {
return
}
// Determine which result to select next
next ? (el = activeEl.nextElementSibling) : (el = activeEl.previousElementSibling)
// If element exists
if (el) {
activeEl.classList.remove("n-active")
el.classList.add("n-active")
el.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
})
activeEl = el
}
},
/**
* Initialize search
*
* @function
*/
initSearch: function () {
var self = this
if (!this.searchInput) {
return
}
// Close user dropdown when input is focused and start fetching the data
this.searchInput.addEventListener("focus", function () {
self.getSearchData()
})
// Listen for typing inside the search input
this.searchInput.addEventListener("keyup", function (event) {
event.stopPropagation()
var activeResult = self.resultsUI.querySelector("li.n-active")
// When Esc is pressed
if (event.keyCode === 27) {
event.preventDefault()
self.clearSearchResults()
self.resultsUI.classList.remove("n-active")
self.searchInput.classList.remove("n-active")
self.searchClose.classList.remove("n-active")
document.documentElement.classList.remove("n-search-active")
self.searchInput.value = ""
this.blur()
return
}
// When arrow up and down are pressed
if (event.keyCode === 40 || event.keyCode === 38) {
event.preventDefault()
// If we already have a highlighted element
if (activeResult) {
event.keyCode === 40
? self.browseSearchResults(activeResult, true)
: self.browseSearchResults(activeResult, false)
// If no elements are highlighted, highlight one
} else {
if (event.keyCode === 40) {
var childNodes = self.resultsUI.querySelectorAll("li")
util.addClassToFirstOfType(childNodes, "n-active")
}
}
return
}
// When Enter is pressed
if (event.keyCode === 13) {
if (activeResult) {
self.resultsUI.classList.remove("n-active")
self.searchClose.classList.remove("n-active")
document.documentElement.classList.remove("n-search-active")
var url = activeResult.querySelector("a").getAttribute("href")
window.location.href = url
self.clearSearchResults()
}
return
}
// Finally, if none of the above conditions matched, do search and show results
var str = util.sanitizeString(self.searchInput.value)
if (str.length > 0) {
self.findSearchResult(str)
self.resultsUI.classList.add("n-active")
self.searchClose.classList.add("n-active")
document.documentElement.classList.add("n-search-active")
// If the input is empty, hide Results UI
} else {
self.clearSearchResults()
self.resultsUI.classList.remove("n-active")
self.searchClose.classList.remove("n-active")
document.documentElement.classList.remove("n-search-active")
}
})
},
}
/**
* Expose a public-facing API
*/
function expose() {
var web = new NordDocs()
web.init()
return web
}
window.nordDocs = expose
})(window, document)
// Call the API!
nordDocs()
---
permalink: search.json
eleventyExcludeFromCollections: true
---
{{ collections.all | searchIndex | dump | safe }}
// @ts-check
/**
* Make a search index string by removing duplicated words
* and removing less useful, common short words
*
* @param {String} text
*/
function indexer(text) {
if (!text) {
return ""
}
// all lower case, please
text = text.toLowerCase()
// remove all html elements and new lines
var re = /<.*?>/gis
var plain = unescape(text.replace(re, " "))
return plain
.replace(/\b(\.|,|"|#|'|;|:|“|”|‘|’)\b/gi, " ") // remove punctuation at word boundaries
.replace(/\.|,|\?|\n/g, " ") // remove punctuation and new lines
.replace(/[ ]{2,}/g, " ") // remove repeated spaces
.replace(/\|/g, "") // remove typescript stuff
.replace(/\*/g, "") // Remove asterisks
.replace(/\\/g, "") // Remove \w
.trim()
}
/**
* Clean up original content for json
*
* @param {String} text
*/
function squash(text) {
if (!text) {
return ""
}
// Remove headings etc first
var noheading = text.replace(/\<h1(.*)\>(.*)\<\/h1\>/, "")
var nosubnav = noheading.replace(/\<div class=\"n-tabs\"\>[^]+(.*)\<\/ul\>[^]\<\/div\>/, "")
// remove all html elements and new lines
var re = /<.*?>/gis
var plain = unescape(nosubnav.replace(re, " "))
return plain
.replace(/\b("|')\b/gi, " ") // remove things that might cause issues
.replace(/\n/g, " ") //remove newlines, hashtags, &amps;, repeated spaces
.replace(/#(\S*)/g, "")
.replace(/&(\S*)/g, "")
.replace(/[ ]{2,}/g, " ")
.replace(/\|/g, "")
.replace(/\\/g, "")
.substring(0, 200) // Only 200 first chars
.trim()
}
module.exports = function searchIndex(collection) {
const search = collection
.filter(page => !page.data.excludeFromSearch)
.map(({ templateContent, url, data }) => {
const { description = "", title = "" } = data
const text = `${squash(description)} ${squash(templateContent)}`.trim()
const keywords = `${indexer(`${title} ${templateContent}`)} ${indexer(description)}`.trim()
return {
url,
title: title ? title : "Nord Design System",
text,
readabletitle: indexer(title),
keywords,
}
})
return { search }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment