app.js 12.1 KB
/* ============================================================
   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 = `
        <div class="result-path">${d.crumbs.join(' › ')}</div>
        <div class="result-title">${escapeHtml(d.title)}</div>
        <div class="result-snippet">${snippet(d.text, query)}</div>
      `;
      li.addEventListener('click', () => {
        navigateTo(d.href);
      });
      resultsEl.appendChild(li);
    });
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
  }
  function snippet(text, query) {
    const lower = text.toLowerCase();
    const q = query.toLowerCase();
    let i = lower.indexOf(q);
    if (i < 0) {
      const t = tokenize(query)[0];
      if (t) i = lower.indexOf(t);
    }
    if (i < 0) i = 0;
    const start = Math.max(0, i - 60);
    const end = Math.min(text.length, i + 140);
    let frag = text.slice(start, end);
    if (start > 0) frag = '…' + frag;
    if (end < text.length) frag = frag + '…';
    // highlight
    const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
    return escapeHtml(frag).replace(re, m => `<mark>${escapeHtml(m)}</mark>`);
  }

  let timer = null;
  searchEl.addEventListener('input', () => {
    clearTimeout(timer);
    const v = searchEl.value;
    timer = setTimeout(async () => {
      await runSearch(v);
      if (v.trim()) switchTab('search');
    }, 120);
  });
  searchEl.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      runSearch(searchEl.value);
      switchTab('search');
    } else if (e.key === 'Escape') {
      searchEl.value = '';
      runSearch('');
      switchTab('reader');
    }
  });
  document.getElementById('clear-search').addEventListener('click', () => {
    searchEl.value = '';
    runSearch('');
    switchTab('reader');
    searchEl.focus();
  });

  // ──────────────────────────────────────────────
  //  GLOBAL HOTKEYS
  // ──────────────────────────────────────────────
  document.addEventListener('keydown', (e) => {
    const tag = (e.target.tagName || '').toLowerCase();
    if (tag === 'input' || tag === 'textarea') return;
    if (e.key === '/') {
      e.preventDefault();
      searchEl.focus();
      searchEl.select();
    }
  });

  // ──────────────────────────────────────────────
  //  MOBILE
  // ──────────────────────────────────────────────
  burger.addEventListener('click', () => document.body.classList.toggle('sidebar-open'));

  // ──────────────────────────────────────────────
  //  BOOT
  // ──────────────────────────────────────────────
  renderMenu();
  if (!location.hash) location.hash = '#/01-overview/index.html';
  else onHashChange();
  onHashChange();

  // helper: load script
  function loadScript(src) {
    return new Promise((res, rej) => {
      const s = document.createElement('script');
      s.src = src;
      s.onload = () => res();
      s.onerror = () => rej(new Error('Failed to load ' + src));
      document.head.appendChild(s);
    });
  }
})();