const toggle = document.querySelector(".menu-toggle"),
sidebar = document.querySelector(".sidebar"),
overlay = document.querySelector(".overlay")
if (toggle && sidebar && overlay) {
const close = () => {
sidebar.classList.remove("open")
overlay.classList.remove("open")
}
toggle.addEventListener("click", () => {
sidebar.classList.toggle("open")
overlay.classList.toggle("open")
})
overlay.addEventListener("click", close)
sidebar
.querySelectorAll("a")
.forEach((a) => a.addEventListener("click", close))
}
if (sidebar) {
const savedPos = sessionStorage.getItem("sidebarScroll")
if (savedPos) sidebar.scrollTop = parseInt(savedPos, 10)
sidebar.addEventListener("scroll", () =>
sessionStorage.setItem("sidebarScroll", sidebar.scrollTop),
)
}
const themeToggle = document.querySelector(".theme-toggle"),
setTheme = (t) => {
document.documentElement.setAttribute("data-theme", t)
localStorage.setItem("theme", t)
},
getTheme = () =>
document.documentElement.getAttribute("data-theme") || "light"
themeToggle?.addEventListener("click", () =>
setTheme(getTheme() === "dark" ? "light" : "dark"),
)
const searchBtn = document.querySelector(".search-button"),
searchOverlay = document.querySelector(".search-modal-overlay"),
searchInput = document.querySelector(".search-input"),
searchResults = document.querySelector(".search-results"),
searchClose = document.querySelector(".search-close")
let searchIndex = null,
selectedIdx = 0,
results = []
const openSearch = () => {
searchOverlay.classList.add("open")
searchInput.focus()
},
closeSearch = () => {
searchOverlay.classList.remove("open")
searchInput.value = ""
searchResults.innerHTML = ""
selectedIdx = 0
results = []
}
const loadIndex = async () => {
if (searchIndex) return
try {
searchIndex = await (await fetch("{{base}}search-index.json")).json()
} catch (e) {
console.warn("Search index load failed:", e)
}
}
const tokenize = (t) => {
const r = []
let c = ""
for (const ch of t) {
if (
/[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(
ch,
)
) {
if (c) {
r.push(c.toLowerCase())
c = ""
}
r.push(ch)
} else if (/[a-zA-Z0-9_]/.test(ch)) c += ch
else if (c) {
r.push(c.toLowerCase())
c = ""
}
}
if (c) r.push(c.toLowerCase())
return r
}
const search = async (q) => {
if (!q.trim()) {
searchResults.innerHTML = ""
results = []
return
}
await loadIndex()
if (!searchIndex) {
searchResults.innerHTML =
'<div class="search-empty">Index unavailable</div>'
return
}
const tokens = tokenize(q)
if (!tokens.length) {
searchResults.innerHTML = ""
results = []
return
}
const k1 = 1.2,
b = 0.75,
scores = new Map()
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i],
isLast = i === tokens.length - 1
let terms =
isLast && tok.length >= 2
? Object.keys(searchIndex.index).filter((t) => t.startsWith(tok))
: searchIndex.index[tok]
? [tok]
: []
for (const term of terms) {
const posts = searchIndex.index[term] || [],
df = searchIndex.df[term] || 1,
idf = Math.log((searchIndex.doc_count - df + 0.5) / (df + 0.5) + 1)
for (const p of posts) {
const doc = searchIndex.documents[p.doc_idx]
if (!doc) continue
const boost = p.field === "Title" ? 10 : p.field === "Heading" ? 5 : 1,
score =
idf *
((p.tf * (k1 + 1)) /
(p.tf +
k1 * (1 - b + (b * doc.body.length) / searchIndex.avg_dl))) *
boost
if (!scores.has(p.doc_idx))
scores.set(p.doc_idx, { score: 0, matches: new Set() })
const e = scores.get(p.doc_idx)
e.score += score
e.matches.add(term)
}
}
}
results = Array.from(scores.entries())
.map(([idx, d]) => {
const doc = searchIndex.documents[idx]
let snip = ""
if (doc.body) {
const bl = doc.body.toLowerCase()
let fp = -1
for (const m of d.matches) {
const pos = bl.indexOf(m)
if (pos !== -1 && (fp === -1 || pos < fp)) fp = pos
}
const st = Math.max(0, fp - 50),
en = Math.min(doc.body.length, st + 150)
snip = doc.body.slice(st, en)
if (st > 0) snip = "..." + snip
if (en < doc.body.length) snip += "..."
}
return { ...doc, score: d.score, snippet: snip }
})
.sort((a, b) => b.score - a.score)
.slice(0, 10)
selectedIdx = 0
render()
}
const render = () => {
if (!results.length) {
searchResults.innerHTML = '<div class="search-empty">No results</div>'
return
}
searchResults.innerHTML = results
.map(
(r, i) =>
'<a href="' +
r.url +
'" class="search-result' +
(i === selectedIdx ? " selected" : "") +
'"><div class="search-result-title">' +
r.title +
"</div>" +
(r.snippet
? '<div class="search-result-snippet">' + r.snippet + "</div>"
: "") +
"</a>",
)
.join("")
}
searchBtn?.addEventListener("click", openSearch)
searchClose?.addEventListener("click", closeSearch)
searchOverlay?.addEventListener("click", (e) => {
if (e.target === searchOverlay) closeSearch()
})
let timeout = null
searchInput?.addEventListener("input", () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => search(searchInput.value), 150)
})
searchInput?.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeSearch()
else if (e.key === "ArrowDown") {
e.preventDefault()
if (selectedIdx < results.length - 1) {
selectedIdx++
render()
}
} else if (e.key === "ArrowUp") {
e.preventDefault()
if (selectedIdx > 0) {
selectedIdx--
render()
}
} else if (e.key === "Enter" && results[selectedIdx]) {
e.preventDefault()
location.href = results[selectedIdx].url
}
})
document.addEventListener("keydown", (e) => {
if (
(e.key === "/" && !(e.target instanceof HTMLInputElement)) ||
((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k")
) {
e.preventDefault()
openSearch()
}
})
const scrollToHash = () => {
const hash = location.hash
if (hash) {
const target = document.querySelector(hash)
if (target) {
setTimeout(
() => target.scrollIntoView({ behavior: "smooth", block: "start" }),
100,
)
}
}
}
scrollToHash()
window.addEventListener("hashchange", scrollToHash)
document.querySelectorAll('a[href^="#"]').forEach((a) =>
a.addEventListener("click", (e) => {
const hash = a.getAttribute("href")
const target = document.querySelector(hash)
if (target) {
e.preventDefault()
target.scrollIntoView({ behavior: "smooth", block: "start" })
history.pushState(null, null, hash)
}
}),
)
const mobileMenuBtn = document.querySelector("[data-mobile-menu]"),
mobileSearchBtn = document.querySelector("[data-mobile-search]"),
mobileThemeBtn = document.querySelector("[data-mobile-theme]")
mobileMenuBtn?.addEventListener("click", () => {
if (sidebar && overlay) {
sidebar.classList.toggle("open")
overlay.classList.toggle("open")
}
})
mobileSearchBtn?.addEventListener("click", openSearch)
mobileThemeBtn?.addEventListener("click", () =>
setTheme(getTheme() === "dark" ? "light" : "dark"),
)