initial: zero-trust markdown tutorials site
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>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// 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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user