Last active
April 6, 2022 14:07
-
-
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/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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'>↑↓ 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 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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
permalink: search.json | |
eleventyExcludeFromCollections: true | |
--- | |
{{ collections.all | searchIndex | dump | safe }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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, &s;, 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