commit 8fe35bff4c479f5c399eab8a081a2386b11ebe10
parent 111cb37167ad13d26e564cc0a77d1a953bc0e824
Author: breadcat <breadcat@users.noreply.github.com>
Date: Wed, 30 Jul 2025 15:49:34 +0100
Integrate lunr.js search
Diffstat:
4 files changed, 146 insertions(+), 6 deletions(-)
diff --git a/config.toml b/config.toml
@@ -21,3 +21,6 @@ disableFastRender=true
dream = "dream"
language = "language"
memoir = "memoir"
+
+[outputs]
+ home = ["HTML", "JSON"]
diff --git a/layouts/index.json.json b/layouts/index.json.json
@@ -0,0 +1,10 @@
+{{- $.Scratch.Add "index" slice -}}
+{{- range .Site.RegularPages -}}
+ {{- $.Scratch.Add "index" (dict
+ "title" .Title
+ "permalink" .Permalink
+ "content" .Plain
+ "summary" .Summary
+ ) -}}
+{{- end -}}
+{{- $.Scratch.Get "index" | jsonify -}}
diff --git a/themes/Brine/layouts/_default/baseof.html b/themes/Brine/layouts/_default/baseof.html
@@ -17,7 +17,7 @@
aside ul{list-style:none;white-space:nowrap;padding:10px}
aside a,aside li{color:#e0e0e0}
aside{background:#212121;padding:3.5vw;top:0}
- aside ul form input{background:#212121;color:#e0e0e0;padding:1.5vh;border:1px solid #e0e0e0;border-radius:1vh}
+ aside ul input{background:#212121;color:#e0e0e0;padding:1.5vh;border:1px solid #e0e0e0;border-radius:1vh}
main{margin:8vh auto;max-width:90%}
main h1{font-size:xx-large;margin-top:8vh}
input:checked,input:checked+label{opacity:50%;text-decoration:line-through}
@@ -56,16 +56,13 @@
</ul>
<ul>
<li>
- <form action="https://duckduckgo.com/" method="get">
- <input name="sites" type="hidden" value="{{ .Site.BaseURL }}">
- <input aria-label="Search..." name="q" type="text" placeholder="Search..." >
- </form>
+ {{ partial "search.html" . }}
</li>
</ul>
<ul>
<li>© {{ now.Format "2006" }}</li>
</ul>
</aside>
-<main>
+<main id="content">
{{ block "main" . }}{{ .Content }}{{ end }}
</main>
diff --git a/themes/Brine/layouts/partials/search.html b/themes/Brine/layouts/partials/search.html
@@ -0,0 +1,130 @@
+<script src="https://unpkg.com/lunr/lunr.js"></script>
+<input type="text" id="searchBox" placeholder="Search...">
+<script>
+ let idx = null;
+ let store = null;
+ let originalContent = "";
+
+ // Escape regex special chars in search terms
+ function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ // Highlight matched terms in text
+ function highlightTerms(text, terms) {
+ if (!terms.length) return text;
+ const regex = new RegExp(`\\b(${terms.map(t => escapeRegExp(t)).join("|")})\\b`, "gi");
+ return text.replace(regex, "<mark>$1</mark>");
+ }
+
+ // Clean text by normalizing whitespace and removing trailing invisible unicode chars
+ function cleanText(text) {
+ let cleaned = text.replace(/\s+/g, " "); // Normalize whitespace to single space
+ cleaned = cleaned.trim();
+ cleaned = cleaned.replace(/[\u200B\u00A0]+$/g, ""); // Remove zero-width and non-breaking spaces at end
+ return cleaned;
+ }
+
+ // Remove replacement chars (? / U+FFFD) from text
+ function removeReplacementChars(text) {
+ return text.replace(/\uFFFD/g, "");
+ }
+
+ // Get snippet around first matched term, highlight terms, and clean text
+ function getSnippetWithHighlight(text, terms, contextWords = 40) {
+ if (!terms.length) return text;
+
+ // Remove replacement chars first
+ text = removeReplacementChars(text);
+
+ const div = document.createElement("div");
+ div.innerHTML = text;
+ const plainText = div.textContent || div.innerText || "";
+
+ const words = plainText.split(/\s+/);
+ let matchIndex = -1;
+
+ for (let i = 0; i < words.length; i++) {
+ if (terms.some(term => words[i].toLowerCase().includes(term.toLowerCase()))) {
+ matchIndex = i;
+ break;
+ }
+ }
+
+ if (matchIndex === -1) {
+ const snippet = words.slice(0, contextWords).join(" ");
+ return cleanText(snippet) + "…";
+ }
+
+ const start = Math.max(0, Math.floor(matchIndex - contextWords / 2));
+ const end = Math.min(words.length, Math.floor(matchIndex + contextWords / 2));
+ const snippet = words.slice(start, end).join(" ");
+
+ const highlighted = highlightTerms(snippet, terms);
+ return cleanText(highlighted) + "…";
+ }
+
+ // Load Lunr index and data
+ fetch("/index.json")
+ .then(response => response.json())
+ .then(data => {
+ store = data;
+
+ idx = lunr(function () {
+ this.ref("permalink");
+ this.field("title");
+ this.field("content");
+
+ data.forEach(doc => this.add(doc), this);
+ });
+ });
+
+ // Save original content for restoring
+ document.addEventListener("DOMContentLoaded", () => {
+ const content = document.getElementById("content");
+ if (content) {
+ originalContent = content.innerHTML;
+ }
+ });
+
+ // Search input event handler
+ document.getElementById("searchBox").addEventListener("input", function () {
+ const query = this.value.trim();
+ const content = document.getElementById("content");
+
+ if (!idx || !store || !content) return;
+
+ if (!query) {
+ content.innerHTML = originalContent;
+ return;
+ }
+
+ const results = idx.search(query);
+ const terms = query.split(/\s+/).filter(term => term.length > 1);
+
+ if (results.length === 0) {
+ content.innerHTML = `<p>No results found for "<strong>${query}</strong>".</p>`;
+ return;
+ }
+
+ let html = `<h2>Search Results for "<em>${query}</em>":</h2><ul>`;
+ results.forEach(result => {
+ const item = store.find(page => page.permalink === result.ref);
+ if (!item) return; // safety check
+
+ // Remove replacement chars from summary before snippet
+ let cleanSummary = removeReplacementChars(item.summary);
+
+ const highlightedTitle = highlightTerms(item.title, terms);
+ const highlightedSummary = getSnippetWithHighlight(cleanSummary, terms, 40);
+
+ html += `
+ <li>
+ <a href="${item.permalink}">${highlightedTitle}</a>
+ <p>${highlightedSummary}</p>
+ </li>`;
+ });
+ html += "</ul>";
+ content.innerHTML = html;
+ });
+</script>