diff --git a/config.toml b/config.toml index 129767c..2c8e33c 100644 --- a/config.toml +++ b/config.toml @@ -4,11 +4,19 @@ description = "broke my bed now it's dead" # The URL the site will be built for base_url = "https://blog.x4m3.rocks" +# css compile_sass = true +# rss generate_feed = true feed_filename = "atom.xml" +# search +default_language = "en" +build_search_index = true +[search] +index_format = "elasticlunr_json" + [markdown] highlight_code = true highlight_theme = "gruvbox-light" diff --git a/static/search.js b/static/search.js new file mode 100644 index 0000000..553b1de --- /dev/null +++ b/static/search.js @@ -0,0 +1,199 @@ +function debounce(func, wait) { + var timeout; + + return function () { + var context = this; + var args = arguments; + clearTimeout(timeout); + + timeout = setTimeout(function () { + timeout = null; + func.apply(context, args); + }, wait); + }; +} + +// Taken from mdbook +// The strategy is as follows: +// First, assign a value to each word in the document: +// Words that correspond to search terms (stemmer aware): 40 +// Normal words: 2 +// First word in a sentence: 8 +// Then use a sliding window with a constant number of words and count the +// sum of the values of the words within the window. Then use the window that got the +// maximum sum. If there are multiple maximas, then get the last one. +// Enclose the terms in . +function makeTeaser(body, terms) { + var TERM_WEIGHT = 40; + var NORMAL_WORD_WEIGHT = 2; + var FIRST_WORD_WEIGHT = 8; + var TEASER_MAX_WORDS = 30; + + var stemmedTerms = terms.map(function (w) { + return elasticlunr.stemmer(w.toLowerCase()); + }); + var termFound = false; + var index = 0; + var weighted = []; // contains elements of ["word", weight, index_in_document] + + // split in sentences, then words + var sentences = body.toLowerCase().split(". "); + + for (var i in sentences) { + var words = sentences[i].split(" "); + var value = FIRST_WORD_WEIGHT; + + for (var j in words) { + var word = words[j]; + + if (word.length > 0) { + for (var k in stemmedTerms) { + if (elasticlunr.stemmer(word).startsWith(stemmedTerms[k])) { + value = TERM_WEIGHT; + termFound = true; + } + } + weighted.push([word, value, index]); + value = NORMAL_WORD_WEIGHT; + } + + index += word.length; + index += 1; // ' ' or '.' if last word in sentence + } + + index += 1; // because we split at a two-char boundary '. ' + } + + if (weighted.length === 0) { + return body; + } + + var windowWeights = []; + var windowSize = Math.min(weighted.length, TEASER_MAX_WORDS); + // We add a window with all the weights first + var curSum = 0; + for (var i = 0; i < windowSize; i++) { + curSum += weighted[i][1]; + } + windowWeights.push(curSum); + + for (var i = 0; i < weighted.length - windowSize; i++) { + curSum -= weighted[i][1]; + curSum += weighted[i + windowSize][1]; + windowWeights.push(curSum); + } + + // If we didn't find the term, just pick the first window + var maxSumIndex = 0; + if (termFound) { + var maxFound = 0; + // backwards + for (var i = windowWeights.length - 1; i >= 0; i--) { + if (windowWeights[i] > maxFound) { + maxFound = windowWeights[i]; + maxSumIndex = i; + } + } + } + + var teaser = []; + var startIndex = weighted[maxSumIndex][2]; + for (var i = maxSumIndex; i < maxSumIndex + windowSize; i++) { + var word = weighted[i]; + if (startIndex < word[2]) { + // missing text from index to start of `word` + teaser.push(body.substring(startIndex, word[2])); + startIndex = word[2]; + } + + // add around search terms + if (word[1] === TERM_WEIGHT) { + teaser.push(""); + } + startIndex = word[2] + word[0].length; + teaser.push(body.substring(word[2], startIndex)); + + if (word[1] === TERM_WEIGHT) { + teaser.push(""); + } + } + teaser.push("…"); + return teaser.join(""); +} + +function formatSearchResultItem(item, terms) { + return '
' + + `${item.doc.title}` + + `
${makeTeaser(item.doc.body, terms)}
` + + '
'; +} + +function initSearch() { + var $searchInput = document.getElementById("search"); + var $searchResults = document.querySelector(".search-results"); + var $searchResultsItems = document.querySelector(".search-results__items"); + var MAX_ITEMS = 10; + + var options = { + bool: "AND", + fields: { + title: {boost: 2}, + body: {boost: 1}, + } + }; + var currentTerm = ""; + var index; + + var initIndex = async function () { + if (index === undefined) { + index = fetch("/search_index.en.json") + .then( + async function(response) { + return await elasticlunr.Index.load(await response.json()); + } + ); + } + let res = await index; + return res; + } + + $searchInput.addEventListener("keyup", debounce(async function() { + var term = $searchInput.value.trim(); + if (term === currentTerm) { + return; + } + $searchResults.style.display = term === "" ? "none" : "block"; + $searchResultsItems.innerHTML = ""; + currentTerm = term; + if (term === "") { + return; + } + + var results = (await initIndex()).search(term, options); + if (results.length === 0) { + $searchResults.style.display = "none"; + return; + } + + for (var i = 0; i < Math.min(results.length, MAX_ITEMS); i++) { + var item = document.createElement("li"); + item.innerHTML = formatSearchResultItem(results[i], term.split(" ")); + $searchResultsItems.appendChild(item); + } + }, 150)); + + window.addEventListener('click', function(e) { + if ($searchResults.style.display == "block" && !$searchResults.contains(e.target)) { + $searchResults.style.display = "none"; + } + }); +} + + +if (document.readyState === "complete" || + (document.readyState !== "loading" && !document.documentElement.doScroll) +) { + initSearch(); +} else { + document.addEventListener("DOMContentLoaded", initSearch); +} diff --git a/templates/index.html b/templates/index.html index 14789ac..15a0e8c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -59,6 +59,13 @@

{{ config.title }}
{{ config.description }}

+ +
+ +
+
+
+

{% endblock header %} @@ -92,6 +99,9 @@

{% block after_main %} {% endblock after_main %} + + +