/* ============================================================ LemonBoss doc-site shell — menu + hash router + search ============================================================ */ (async () => { // ── Menu (loaded synchronously via separate script tag if available) ── await loadScript('./assets/menu.js'); const MENU = window.LEMONBOSS_MENU || []; const menuEl = document.getElementById('menu'); const readerEl = document.getElementById('reader'); const searchEl = document.getElementById('search'); const resultsEl = document.getElementById('results'); const summaryEl = document.getElementById('search-summary'); const tabBtns = document.querySelectorAll('.tab'); const tabSearch = document.getElementById('tab-search'); const countEl = document.getElementById('search-count'); const burger = document.getElementById('burger'); const themeBtn = document.getElementById('theme'); // ────────────────────────────────────────────── // THEME // ────────────────────────────────────────────── const storedTheme = localStorage.getItem('lemonboss-theme'); if (storedTheme) document.documentElement.dataset.theme = storedTheme; themeBtn.addEventListener('click', () => { const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = next; localStorage.setItem('lemonboss-theme', next); }); // ────────────────────────────────────────────── // MENU RENDER // ────────────────────────────────────────────── function renderMenu() { MENU.forEach(ch => menuEl.appendChild(renderNode(ch, 'chapter'))); } function renderNode(node, kind) { const wrap = document.createElement('div'); wrap.className = `menu-${kind}`; wrap.dataset.id = node.id; const link = document.createElement('a'); link.className = 'menu-link'; link.href = '#/' + node.href.replace(/#.*$/, '') + (node.href.includes('#') ? node.href.slice(node.href.indexOf('#')) : ''); link.dataset.href = node.href; if (node.children) { const caret = document.createElement('span'); caret.className = 'caret'; caret.textContent = '▾'; caret.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); wrap.classList.toggle('collapsed'); }); link.appendChild(caret); } else { const dot = document.createElement('span'); dot.className = 'caret'; dot.textContent = '·'; link.appendChild(dot); } const num = document.createElement('span'); num.className = 'menu-num'; num.textContent = node.num; link.appendChild(num); const txt = document.createElement('span'); txt.textContent = node.title; link.appendChild(txt); link.addEventListener('click', (e) => { e.preventDefault(); navigateTo(node.href); if (window.innerWidth < 768) document.body.classList.remove('sidebar-open'); }); wrap.appendChild(link); if (node.children) { const cs = document.createElement('div'); cs.className = 'children'; const childKind = kind === 'chapter' ? 'section' : 'sub'; node.children.forEach(c => cs.appendChild(renderNode(c, childKind))); wrap.appendChild(cs); } return wrap; } function highlightMenu(href) { document.querySelectorAll('.menu-link').forEach(a => a.classList.remove('active')); const target = stripHash(href); let best = null; document.querySelectorAll('.menu-link').forEach(a => { const ah = a.dataset.href || ''; if (stripHash(ah) === target) { if (!best || (ah.includes('#') && !best.dataset.href.includes('#'))) best = a; if (ah === href) best = a; // exact match wins } }); if (best) { best.classList.add('active'); // expand all ancestors let p = best.parentElement; while (p && p !== menuEl) { if (p.classList.contains('collapsed')) p.classList.remove('collapsed'); p = p.parentElement; } best.scrollIntoView({ block: 'nearest' }); } } const stripHash = (h) => h.split('#')[0]; // ────────────────────────────────────────────── // HASH ROUTER // ────────────────────────────────────────────── function navigateTo(href) { location.hash = '#/' + href; } function onHashChange() { const raw = location.hash.replace(/^#\//, ''); const href = raw || '01-overview/index.html'; readerEl.src = './chapters/' + href; highlightMenu(href); // switch back to reader pane switchTab('reader'); } window.addEventListener('hashchange', onHashChange); // ────────────────────────────────────────────── // TABS // ────────────────────────────────────────────── function switchTab(name) { tabBtns.forEach(b => b.classList.toggle('active', b.dataset.pane === name)); document.querySelectorAll('.pane').forEach(p => p.classList.toggle('active', p.classList.contains('pane-' + name)) ); } tabBtns.forEach(b => b.addEventListener('click', () => switchTab(b.dataset.pane))); // ────────────────────────────────────────────── // SEARCH // ─ Loads search-index.json (built by scripts/build-search-index.js) // ─ Tokenization: lowercase, split on whitespace+punct, + Chinese bigram // ────────────────────────────────────────────── let INDEX = null; async function loadIndex() { if (INDEX) return INDEX; try { const r = await fetch('./assets/search-index.json'); INDEX = await r.json(); } catch (e) { INDEX = { docs: [], invIndex: {} }; console.warn('[search] no index available — run scripts/build-search-index.js'); } return INDEX; } function tokenize(text) { const tokens = []; const lower = text.toLowerCase(); // ASCII words / digits (lower.match(/[a-z0-9_]+/g) || []).forEach(w => tokens.push(w)); // CJK bigrams (一-鿿 plus extensions) const cjk = lower.match(/[㐀-鿿]+/g) || []; cjk.forEach(run => { if (run.length === 1) tokens.push(run); else { for (let i = 0; i < run.length - 1; i++) tokens.push(run.slice(i, i + 2)); } }); return tokens; } async function runSearch(query) { if (!query || query.trim().length === 0) { resultsEl.innerHTML = ''; summaryEl.textContent = '输入关键词开始搜索'; countEl.textContent = '0'; return; } const idx = await loadIndex(); const terms = tokenize(query); if (terms.length === 0) { resultsEl.innerHTML = ''; summaryEl.textContent = `「${query}」无可识别 token`; countEl.textContent = '0'; return; } // score docs by term frequency sum const scores = new Map(); let hitDocs = null; for (const t of terms) { const posting = idx.invIndex[t]; if (!posting) continue; const docs = new Set(); for (const [docId, tf] of posting) { scores.set(docId, (scores.get(docId) || 0) + tf); docs.add(docId); } hitDocs = hitDocs ? new Set([...hitDocs].filter(d => docs.has(d))) : docs; } // fallback: if intersection empty, use union const candidates = (hitDocs && hitDocs.size > 0) ? [...hitDocs] : [...scores.keys()]; candidates.sort((a, b) => scores.get(b) - scores.get(a)); const top = candidates.slice(0, 50); renderResults(top, query, idx); } function renderResults(docIds, query, idx) { resultsEl.innerHTML = ''; const docs = idx.docs; if (docIds.length === 0) { summaryEl.textContent = `「${query}」没有命中`; countEl.textContent = '0'; return; } summaryEl.textContent = `「${query}」命中 ${docIds.length} 条`; countEl.textContent = String(docIds.length); docIds.forEach(id => { const d = docs[id]; if (!d) return; const li = document.createElement('li'); li.className = 'result-item'; li.innerHTML = `