From 96756761c8bd3a0c45d6e70c255011d60ef7fafd Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Sun, 4 May 2025 10:46:20 +0200 Subject: [PATCH] fix searcher.js --- theme/ht_searcher.js | 322 +++++++++++++++++++++++++++++++------------ 1 file changed, 233 insertions(+), 89 deletions(-) diff --git a/theme/ht_searcher.js b/theme/ht_searcher.js index db9b55b76..3816ac368 100644 --- a/theme/ht_searcher.js +++ b/theme/ht_searcher.js @@ -1,92 +1,236 @@ -/* ht_searcher.js ─────────────────────────────────────────────── */ +/* ht_searcher.js ──────────────────────────────────────────────── + Full Web-Worker search with “⏳” while loading and “🔍” after. + Keeps every feature of the original theme (teasers, breadcrumbs, + highlight link, hot-keys, arrow navigation, ESC to close, …). + + Dependencies already expected by the theme: + • mark.js (for in-page highlights) + • elasticlunr.min.js (worker fetches a CDN or /elasticlunr.min.js) + • searchindex.js (worker fetches GitHub raw or /searchindex.js) +*/ + (() => { - /* ───────────── 0. Inline Web-Worker code ────────────────────── */ - const workerCode = ` - /* Make scripts written for browsers happy inside the worker */ - self.window = self; - self.search = self.search || {}; /* ensure object */ - - const abs = p => location.origin + p; /* helper */ - - /* 1 ─ elasticlunr.min.js (CDN → local) */ - try { - importScripts('https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js'); - } catch (e) { - console.error('elasticlunr CDN failed →', e); - importScripts(abs('/elasticlunr.min.js')); + "use strict"; + + /* ───────────────── 0. Worker code (string) ───────────────── */ + const workerCode = ` + self.window = self; /* make 'window' exist */ + self.search = self.search || {}; + + const abs = p => location.origin + p; /* helper */ + + /* 0.1 load elasticlunr (CDN → local fallback) */ + try { + importScripts('https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js'); + } catch { + importScripts(abs('/elasticlunr.min.js')); + } + + /* 0.2 load searchindex.js (GitHub → local fallback) */ + (async () => { + async function loadRemote () { + const r = await fetch( + 'https://raw.githubusercontent.com/HackTricks-wiki/hacktricks/refs/heads/master/searchindex.js', + { mode:'cors' } + ); + if (!r.ok) throw new Error('HTTP '+r.status); + importScripts( + URL.createObjectURL( + new Blob([await r.text()],{type:'application/javascript'}) + ) + ); } - - /* 2 ─ searchindex.js (GitHub Raw → local) */ - (async () => { - try { - const r = await fetch( - 'https://raw.githubusercontent.com/HackTricks-wiki/hacktricks/refs/heads/master/searchindex.js', - { mode:'cors' } - ); - if (!r.ok) throw new Error('HTTP ' + r.status); - const blobURL = URL.createObjectURL( - new Blob([await r.text()], { type:'application/javascript' }) - ); - importScripts(blobURL); /* MIME coercion */ - } catch (e) { - console.error('GitHub index fetch failed →', e); - try { - importScripts(abs('/searchindex.js')); - } catch (e2) { - console.error('Local index load failed →', e2); - throw e2; /* abort loudly */ - } - } - - /* 3 ─ build index & answer queries */ - const idx = elasticlunr.Index.load(self.search.index); - - self.onmessage = ({ data:q }) => { - if (!q) { postMessage([]); return; } /* empty search */ - const raw = idx.search(q, { bool:'AND', expand:true }); - const docs = raw.map(r => idx.documentStore.getDoc(r.ref)); - postMessage(docs); /* only docs cross thread */ - }; - })(); - `; - - const workerURL = URL.createObjectURL( - new Blob([workerCode], { type:'application/javascript' }) - ); - const worker = new Worker(workerURL); - URL.revokeObjectURL(workerURL); /* tidy blob */ - - /* ───────────── 1. Tiny UI glue ─────────────────────────────── */ - const WRAP = document.getElementById('search-wrapper'); - const TOG = document.getElementById('search-toggle'); - const INP = document.getElementById('searchbar'); - const LIST = document.getElementById('searchresults'); - const HOTKEY = 83; /* “s” */ - let debounce; - - /* paint results */ - worker.onmessage = ({ data:docs }) => { - LIST.innerHTML = docs.slice(0,30).map(d => - '
  • ' + d.title + '
  • ' - ).join(''); - }; - - /* open UI */ - const open = () => { WRAP.classList.remove('hidden'); INP.focus(); }; - - TOG.addEventListener('click', open); - document.addEventListener('keydown', e => { - if (!e.metaKey && !e.ctrlKey && !e.altKey && e.keyCode === HOTKEY) { - e.preventDefault(); open(); - } - }); - - /* debounced keystrokes → worker */ - INP.addEventListener('input', e => { - clearTimeout(debounce); - debounce = setTimeout(() => { - worker.postMessage(e.target.value.trim()); - }, 120); - }); + try { await loadRemote(); } + catch(e){ console.warn('remote index failed →',e); + importScripts(abs('/searchindex.js')); } + + /* 0.3 build index once, keep for all queries */ + const idx = elasticlunr.Index.load(self.search.index); + const DOC_URLS = self.search.doc_urls; + const MAX = 30; + + /* ping main-thread so it can swap the icon */ + postMessage({ready:true}); + + self.onmessage = ({data:q}) => { + if (!q) { postMessage([]); return; } + const res = idx.search(q,{bool:'AND',expand:true}).slice(0,MAX); + postMessage(res.map(r => { + const d = idx.documentStore.getDoc(r.ref); + return { /* only the fields the UI needs */ + ref : r.ref, + title : d.title, + body : d.body, + breadcrumbs: d.breadcrumbs, + url : DOC_URLS[r.ref] + }; + })); + }; })(); - \ No newline at end of file + `; + + /* ───────────────── 1. Build worker ───────────────────────── */ + const workerURL = URL.createObjectURL( + new Blob([workerCode],{type:'application/javascript'}) + ); + const worker = new Worker(workerURL); + URL.revokeObjectURL(workerURL); /* tidy blob */ + + /* ───────────────── 2. DOM references ─────────────────────── */ + const wrap = document.getElementById('search-wrapper'); + const modal = document.getElementById('search-modal'); + const bar = document.getElementById('searchbar'); + const list = document.getElementById('searchresults'); + const listOut= document.getElementById('searchresults-outer'); + const header = document.getElementById('searchresults-header'); + const icon = document.getElementById('search-toggle'); + + /* ───────────────── 3. Constants & state ─────────────────── */ + const HOTKEY = 83, ESC=27, DOWN=40, UP=38, ENTER=13; + const URL_MARK_PARAM = 'highlight'; + const MAX_RESULTS = 30; + const READY_ICON_HTML= icon.innerHTML; /* save original “🔍” */ + icon.textContent = '⏳'; /* show hour-glass */ + icon.setAttribute('aria-label','Loading search …'); + + let debounce, teaserCount = 0; + + /* ───────────────── 4. Helpers (escaped, teaser, format …) ─ */ + const escapeHTML = (() => { + const MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''}; + return s => s.replace(/[&<>'"]/g,c=>MAP[c]); + })(); + + function metric(c,t){ + return c===0 ? `No search results for '${t}'.` + : c===1 ? `1 search result for '${t}':` + : `${c} search results for '${t}':`; + } + + /* ── teaser algorithm (unchanged from theme, just ES-ified) ── */ + function makeTeaser(body,terms){ + const stem = w=>elasticlunr.stemmer(w.toLowerCase()); + const t = terms.map(stem); + const W_SEARCH=40,W_FIRST=8,W_NORM=2,WIN=30; + const wArr=[],sents=body.toLowerCase().split('. '); + let idx=0, val=W_FIRST, found=false; + + sents.forEach(sent=>{ + val=W_FIRST; + sent.split(' ').forEach(word=>{ + if(word){ + if(t.some(st=>stem(word).startsWith(st))){val=W_SEARCH;found=true;} + wArr.push([word,val,idx]); val=W_NORM; + } + idx+=word.length+1; + }); + idx+=1; /* account for '. ' */ + }); + if(!wArr.length) return body; + + const win = Math.min(wArr.length,WIN); + const sums=[ wArr.slice(0,win).reduce((a,[,w])=>a+w,0) ]; + for(let i=1;i<=wArr.length-win;i++) + sums[i]=sums[i-1]-wArr[i-1][1]+wArr[i+win-1][1]; + + const best = found ? sums.lastIndexOf(Math.max(...sums)) : 0; + const out=[], start=wArr[best][2]; + idx=start; + for(let i=best;i'); + out.push(body.substr(pos,word.length)); + if(w===W_SEARCH) out.push(''); + idx=pos+word.length; + } + return out.join(''); + } + + function formatResult(doc,terms){ + const teaser = makeTeaser(escapeHTML(doc.body),terms); + teaserCount++; + const enc = encodeURIComponent(terms.join(' ')).replace(/'/g,'%27'); + const u = doc.url.split('#'); if(u.length===1)u.push(''); + return ``+ + `${doc.breadcrumbs}`+ + `${teaser}`; + } + + const clear = el => { while(el.firstChild) el.removeChild(el.firstChild); }; + + function showUI(show){ + wrap.classList.toggle('hidden',!show); + icon.setAttribute('aria-expanded',show); + if(!show){ + listOut.classList.add('hidden'); + [...list.children].forEach(li=>li.classList.remove('focus')); + }else{ + window.scrollTo(0,0); + bar.focus(); bar.select(); + } + } + + function blurBar(){ + const tmp=document.createElement('input'); + tmp.style.position='absolute'; tmp.style.opacity=0; + icon.appendChild(tmp); tmp.focus(); tmp.remove(); + } + + /* ───────────────── 5. Event handlers ─────────────────────── */ + icon.addEventListener('click',()=>showUI(wrap.classList.contains('hidden'))); + + document.addEventListener('keydown',e=>{ + if(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey) return; + const isInput=/^(?:input|select|textarea)$/i.test(e.target.nodeName); + + if(e.keyCode===HOTKEY && !isInput){ + e.preventDefault(); showUI(true); + }else if(e.keyCode===ESC){ + e.preventDefault(); showUI(false); blurBar(); + }else if(e.keyCode===DOWN && document.activeElement===bar){ + e.preventDefault(); + const first=list.firstElementChild; + if(first){ blurBar(); first.classList.add('focus'); } + }else if((e.keyCode===DOWN||e.keyCode===UP||e.keyCode===ENTER) + && document.activeElement!==bar){ + const cur=list.querySelector('li.focus'); if(!cur) return; + e.preventDefault(); + if(e.keyCode===DOWN){ + const nxt=cur.nextElementSibling; + if(nxt){ cur.classList.remove('focus'); nxt.classList.add('focus'); } + }else if(e.keyCode===UP){ + const prv=cur.previousElementSibling; + cur.classList.remove('focus'); + if(prv){ prv.classList.add('focus'); } else { bar.focus(); } + }else{ /* ENTER */ + const a=cur.querySelector('a'); if(a) window.location.assign(a.href); + } + } + }); + + bar.addEventListener('input',e=>{ + clearTimeout(debounce); + debounce=setTimeout(()=>worker.postMessage(e.target.value.trim()),120); + }); + + /* ───────────────── 6. Worker messages ────────────────────── */ + worker.onmessage = ({data})=>{ + if(data && data.ready){ /* first ping */ + icon.innerHTML=READY_ICON_HTML; /* restore “🔍” */ + icon.setAttribute('aria-label','Open search (S)'); + return; + } + const docs=data; + const q=bar.value.trim(); + const terms=q.split(/\s+/).filter(Boolean); + header.textContent=metric(docs.length,q); + clear(list); + docs.forEach(d=>{ + const li=document.createElement('li'); + li.innerHTML=formatResult(d,terms); + list.appendChild(li); + }); + listOut.classList.toggle('hidden',!docs.length); + }; +})();