aa36b2905a
Single-binary Go service that renders markdown pages from a runtime volume mount. Targeted at public, no-auth, no-WAF deployment behind a TLS ingress; security posture is defense-in-depth at every layer: - goldmark with no WithUnsafe — raw HTML in author markdown is stripped - CSP without 'unsafe-inline', plus HSTS, COOP, CORP, Permissions-Policy - static handler rejects non-GET/HEAD, directory listings, dotfiles, traversal - content loader rejects symlinks that escape the content root, dotfiles, and .md files larger than 1 MiB - per-page template trees (cloned from layout) so define-blocks don't collide between home/category/page - SIGHUP triggers atomic library swap — live edits on volume, no rebuild Locale layout content/<locale>/<category>/<slug>.md. Categories without _index.md still appear on the home page with a humanized name. Search is a ~70-line vanilla JS scan over /search.json?lang=<locale>; swap for a real indexer if the corpus ever balloons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
2.6 KiB
JavaScript
72 lines
2.6 KiB
JavaScript
// search.js — minimal client-side search.
|
|
// Loads /search.json?lang=<locale>, builds an in-memory index, renders results
|
|
// as the user types in the header search box.
|
|
//
|
|
// Deliberately tiny — no FlexSearch dependency yet (the page set is small,
|
|
// linear scan with case-insensitive substring is plenty). Swap in FlexSearch
|
|
// or Lunr if the corpus grows past a few hundred pages.
|
|
(function () {
|
|
const input = document.getElementById('search-input');
|
|
const results = document.getElementById('search-results');
|
|
if (!input || !results) return;
|
|
|
|
const lang = document.documentElement.lang || 'en';
|
|
let pages = [];
|
|
let loaded = false;
|
|
|
|
function loadIndex() {
|
|
if (loaded) return Promise.resolve();
|
|
return fetch('/search.json?lang=' + encodeURIComponent(lang))
|
|
.then(r => r.json())
|
|
.then(data => { pages = data || []; loaded = true; })
|
|
.catch(() => { /* silent — search just stays empty */ });
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
function search(q) {
|
|
if (!q || q.length < 2) return [];
|
|
const needle = q.toLowerCase();
|
|
const out = [];
|
|
for (const p of pages) {
|
|
const titleHit = p.title && p.title.toLowerCase().includes(needle);
|
|
const summaryHit = p.summary && p.summary.toLowerCase().includes(needle);
|
|
const bodyHit = p.body && p.body.toLowerCase().includes(needle);
|
|
if (titleHit || summaryHit || bodyHit) {
|
|
// crude scoring: title > summary > body
|
|
const score = (titleHit ? 100 : 0) + (summaryHit ? 10 : 0) + (bodyHit ? 1 : 0);
|
|
out.push({ ...p, score });
|
|
}
|
|
if (out.length >= 50) break;
|
|
}
|
|
out.sort((a, b) => b.score - a.score);
|
|
return out.slice(0, 8);
|
|
}
|
|
|
|
function render(matches) {
|
|
if (!matches.length) { results.hidden = true; results.innerHTML = ''; return; }
|
|
results.innerHTML = matches.map(m =>
|
|
`<li><a href="${escapeHtml(m.path)}">
|
|
<span class="title">${escapeHtml(m.title)}</span>
|
|
${m.summary ? `<span class="summary">${escapeHtml(m.summary)}</span>` : ''}
|
|
</a></li>`
|
|
).join('');
|
|
results.hidden = false;
|
|
}
|
|
|
|
let timer = null;
|
|
input.addEventListener('focus', loadIndex);
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
loadIndex().then(() => render(search(input.value.trim())));
|
|
}, 80);
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
// Delay so click on a result registers before hiding.
|
|
setTimeout(() => { results.hidden = true; }, 150);
|
|
});
|
|
})();
|