259 lines
10 KiB
HTML
259 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html class="dark" lang="{{ site.Language.Locale | default `en` }}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="darkreader-lock">
|
|
<meta name="color-scheme" content="dark">
|
|
<title>{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} | {{ site.Title }}{{ end }}</title>
|
|
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@400&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
|
{{ $style := resources.Get "css/starless.css" | minify | fingerprint }}
|
|
<link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}">
|
|
{{ if .Params.math }}
|
|
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
|
|
{{ end }}
|
|
</head>
|
|
<body>
|
|
<div class="site-shell">
|
|
{{ partial "sidebar.html" . }}
|
|
<main class="site-main">
|
|
{{ block "main" . }}{{ end }}
|
|
</main>
|
|
</div>
|
|
<dialog id="search-dialog" class="search-dialog">
|
|
<div class="search-stack">
|
|
<div class="search-field">
|
|
<span class="material-symbols-outlined search-field-icon" aria-hidden="true">search</span>
|
|
<input id="search-input" class="search-input" type="text" placeholder="Search" autocomplete="off" spellcheck="false" aria-label="Search">
|
|
<button class="search-close" type="button" data-search-close aria-label="Close search">
|
|
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="search-panel" data-search-panel hidden>
|
|
<div class="search-results" data-search-results></div>
|
|
<article class="search-preview" data-search-preview>
|
|
<p class="search-placeholder">Search</p>
|
|
</article>
|
|
<div class="search-message" data-search-message hidden></div>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
<script>
|
|
(() => {
|
|
const dialog = document.getElementById("search-dialog");
|
|
const input = document.getElementById("search-input");
|
|
const panelEl = dialog.querySelector("[data-search-panel]");
|
|
const resultsEl = dialog.querySelector("[data-search-results]");
|
|
const previewEl = dialog.querySelector("[data-search-preview]");
|
|
const messageEl = dialog.querySelector("[data-search-message]");
|
|
const openButtons = document.querySelectorAll("[data-search-open]");
|
|
const closeButton = dialog.querySelector("[data-search-close]");
|
|
const indexUrl = "{{ "index.json" | relURL }}";
|
|
const flexSearchUrl = "{{ "js/flexsearch.bundle.min.js" | relURL }}";
|
|
const resultLimit = 10;
|
|
let searchIndex;
|
|
let ready;
|
|
let timer;
|
|
|
|
const loadScript = (src) => new Promise((resolve, reject) => {
|
|
if (window.FlexSearch) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const existing = document.querySelector(`script[src="${src}"]`);
|
|
if (existing) {
|
|
existing.addEventListener("load", resolve, { once: true });
|
|
existing.addEventListener("error", reject, { once: true });
|
|
return;
|
|
}
|
|
const script = document.createElement("script");
|
|
script.src = src;
|
|
script.async = true;
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
|
|
const setup = () => {
|
|
if (ready) return ready;
|
|
ready = Promise.all([
|
|
loadScript(flexSearchUrl),
|
|
fetch(indexUrl).then((response) => {
|
|
if (!response.ok) throw new Error("Search index failed to load");
|
|
return response.json();
|
|
})
|
|
]).then(([, docs]) => {
|
|
searchIndex = new window.FlexSearch.Document({
|
|
tokenize: "forward",
|
|
document: {
|
|
id: "id",
|
|
index: ["title", "summary", "content"],
|
|
store: ["id", "title", "url", "summary", "content"]
|
|
}
|
|
});
|
|
docs.forEach((doc) => searchIndex.add(doc));
|
|
});
|
|
return ready;
|
|
};
|
|
|
|
const hidePanel = () => {
|
|
panelEl.hidden = true;
|
|
resultsEl.hidden = false;
|
|
previewEl.hidden = false;
|
|
messageEl.hidden = true;
|
|
resultsEl.replaceChildren();
|
|
previewEl.replaceChildren();
|
|
messageEl.textContent = "";
|
|
};
|
|
|
|
const showMessage = (message) => {
|
|
panelEl.hidden = false;
|
|
resultsEl.hidden = true;
|
|
previewEl.hidden = true;
|
|
messageEl.hidden = false;
|
|
messageEl.textContent = message;
|
|
};
|
|
|
|
const showResultsPanel = () => {
|
|
panelEl.hidden = false;
|
|
resultsEl.hidden = false;
|
|
previewEl.hidden = false;
|
|
messageEl.hidden = true;
|
|
messageEl.textContent = "";
|
|
};
|
|
|
|
const snippet = (text, query) => {
|
|
const clean = (text || "").replace(/\s+/g, " ").trim();
|
|
if (!clean) return "";
|
|
const lower = clean.toLowerCase();
|
|
const needle = query.toLowerCase().split(/\s+/).find(Boolean) || query.toLowerCase();
|
|
const hit = lower.indexOf(needle);
|
|
const start = Math.max(0, hit - 140);
|
|
const end = Math.min(clean.length, start + 720);
|
|
return `${start ? "... " : ""}${clean.slice(start, end)}${end < clean.length ? " ..." : ""}`;
|
|
};
|
|
|
|
const highlight = (node, text, query) => {
|
|
const terms = query
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.sort((a, b) => b.length - a.length)
|
|
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
const pattern = terms.length && new RegExp(`(${terms.join("|")})`, "gi");
|
|
|
|
node.replaceChildren();
|
|
if (!pattern) {
|
|
node.textContent = text || "";
|
|
return;
|
|
}
|
|
|
|
String(text || "").split(pattern).forEach((part) => {
|
|
if (!part) return;
|
|
if (pattern.test(part)) {
|
|
const mark = document.createElement("mark");
|
|
mark.className = "search-mark";
|
|
mark.textContent = part;
|
|
node.appendChild(mark);
|
|
} else {
|
|
node.appendChild(document.createTextNode(part));
|
|
}
|
|
pattern.lastIndex = 0;
|
|
});
|
|
};
|
|
|
|
const showPreview = (doc, query) => {
|
|
previewEl.replaceChildren();
|
|
const title = document.createElement("a");
|
|
title.className = "search-preview-title";
|
|
title.href = doc.url;
|
|
highlight(title, doc.title, query);
|
|
|
|
const summary = document.createElement("p");
|
|
summary.className = "search-preview-summary";
|
|
highlight(summary, doc.summary || "", query);
|
|
|
|
const body = document.createElement("p");
|
|
body.className = "search-preview-body";
|
|
highlight(body, snippet(doc.content, query), query);
|
|
|
|
previewEl.append(title, summary, body);
|
|
};
|
|
|
|
const flattenResults = (groups) => {
|
|
const seen = new Map();
|
|
groups.forEach((group) => {
|
|
const weight = group.field === "title" ? 0 : group.field === "summary" ? 1 : 2;
|
|
group.result.forEach((item, offset) => {
|
|
const doc = item.doc;
|
|
const current = doc && seen.get(item.id);
|
|
if (!doc || (current && current.weight <= weight)) return;
|
|
seen.set(item.id, { doc, weight, offset });
|
|
});
|
|
});
|
|
return Array.from(seen.values())
|
|
.sort((a, b) => a.weight - b.weight || a.offset - b.offset)
|
|
.slice(0, resultLimit)
|
|
.map((item) => item.doc);
|
|
};
|
|
|
|
const runSearch = () => {
|
|
const query = input.value.trim();
|
|
if (!query) {
|
|
hidePanel();
|
|
return;
|
|
}
|
|
const matches = flattenResults(searchIndex.search(query, { enrich: true, limit: resultLimit }));
|
|
resultsEl.replaceChildren();
|
|
if (!matches.length) {
|
|
showMessage("No results");
|
|
return;
|
|
}
|
|
showResultsPanel();
|
|
matches.forEach((doc, index) => {
|
|
const link = document.createElement("a");
|
|
link.className = "search-result";
|
|
link.href = doc.url;
|
|
link.textContent = doc.title;
|
|
link.addEventListener("mouseenter", () => showPreview(doc, query));
|
|
link.addEventListener("focus", () => showPreview(doc, query));
|
|
resultsEl.appendChild(link);
|
|
if (index === 0) showPreview(doc, query);
|
|
});
|
|
};
|
|
|
|
const queueSearch = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
setup().then(runSearch).catch(() => showMessage("Search unavailable"));
|
|
}, 120);
|
|
};
|
|
|
|
openButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
if (!dialog.open) dialog.showModal();
|
|
requestAnimationFrame(() => input.focus());
|
|
setup().then(runSearch).catch(() => showMessage("Search unavailable"));
|
|
});
|
|
});
|
|
|
|
const resetSearch = () => {
|
|
input.value = "";
|
|
hidePanel();
|
|
};
|
|
|
|
closeButton.addEventListener("click", () => dialog.close());
|
|
dialog.addEventListener("close", resetSearch);
|
|
dialog.addEventListener("click", (event) => {
|
|
if (event.target === dialog) dialog.close();
|
|
});
|
|
input.addEventListener("input", queueSearch);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|