/** * Command palette — global search triggered by Cmd/Ctrl+K or the search button. * * Searches lesson titles, summaries, phase names, languages, types, and * glossary terms entirely client-side from the data already loaded in data.js. * No network requests. No external dependencies. * * API (attached to window.CmdPalette): * CmdPalette.open() — open the palette * CmdPalette.close() — close the palette * * Trigger buttons: any element with the [data-cmd-palette] attribute. */ (function () { 'use strict'; // ── Constants ──────────────────────────────────────────────────────── var PALETTE_ID = 'cmdPalette'; var MAX_RESULTS = 12; var BODY_ATTR = 'data-palette-open'; // ── Module state ───────────────────────────────────────────────────── var _index = null; // lazy-built flat array of searchable items var _activeIdx = -1; var _isOpen = false; var _prevFocus = null; // ── Search index ───────────────────────────────────────────────────── /** * Build the flat search index once from window.PHASES and window.GLOSSARY. * Idempotent: subsequent calls return the cached array. */ function buildIndex() { if (_index !== null) return _index; _index = []; if (typeof PHASES !== 'undefined' && Array.isArray(PHASES)) { for (var i = 0; i < PHASES.length; i++) { var phase = PHASES[i]; for (var j = 0; j < phase.lessons.length; j++) { var lesson = phase.lessons[j]; // Extract the phases/…/… path used for lesson.html?path= var lessonPath = ''; if (lesson.url) { var m = lesson.url.match(/(phases\/[^/?#]+\/[^/?#]+)/); if (m) lessonPath = m[1]; } _index.push({ kind: 'lesson', id: 'l:' + i + ':' + j, phaseId: phase.id, phaseName: phase.name, name: lesson.name || '', summary: lesson.summary || '', keywords: lesson.keywords || '', type: lesson.type || '', lang: lesson.lang || '', status: lesson.status || '', lessonPath: lessonPath, url: lesson.url || '', }); } } } if (typeof GLOSSARY !== 'undefined' && Array.isArray(GLOSSARY)) { for (var k = 0; k < GLOSSARY.length; k++) { var g = GLOSSARY[k]; _index.push({ kind: 'glossary', id: 'g:' + k, name: g.term || '', summary: g.means || '', says: g.says || '', }); } } if (typeof ARTIFACTS !== 'undefined' && Array.isArray(ARTIFACTS)) { for (var a = 0; a < ARTIFACTS.length; a++) { var art = ARTIFACTS[a]; _index.push({ kind: 'artifact', id: 'a:' + a, artKind: art.kind || 'artifact', name: art.name || '', summary: art.description || '', keywords: Array.isArray(art.tags) ? art.tags.join(' ') : '', phaseId: art.phase, lesson: art.lesson, lessonPath: art.lessonPath || '', file: art.file || '', }); } } return _index; } // ── Scoring ────────────────────────────────────────────────────────── function scoreItem(item, q) { // q is already lowercased + trimmed by the caller var name = item.name.toLowerCase(); var summary = (item.summary || '').toLowerCase(); var keywords = (item.keywords || '').toLowerCase(); var phase = (item.phaseName || '').toLowerCase(); var lang = (item.lang || '').toLowerCase(); var type = (item.type || '').toLowerCase(); var says = (item.says || '').toLowerCase(); var s = 0; // Exact full-name match — highest priority if (name === q) return 200; // Substring matches in name (most important signal) if (name.startsWith(q)) s += 100; else if (name.indexOf(q) !== -1) s += 70; // Multi-word query: every word must appear somewhere in name var words = q.split(/\s+/).filter(Boolean); if (words.length > 1) { var allInName = words.every(function (w) { return name.indexOf(w) !== -1; }); if (allInName) { s += (s === 0 ? 65 : 20); } else { // Weaker: every word spread across name + summary + keywords + phase var blob = name + ' ' + summary + ' ' + keywords + ' ' + phase; var allInBlob = words.every(function (w) { return blob.indexOf(w) !== -1; }); if (allInBlob) s += 15; } } // Supporting fields — ordered by expected relevance if (summary.indexOf(q) !== -1) s += 25; if (keywords.indexOf(q) !== -1) s += 22; // H3 headings: dense vocabulary if (says.indexOf(q) !== -1) s += 22; // glossary "what people say" if (phase.indexOf(q) !== -1) s += 18; if (lang.indexOf(q) !== -1) s += 14; if (type.indexOf(q) !== -1) s += 10; // Single-word fallback: word-boundary prefix match on name tokens if (s === 0 && words.length === 1) { var nameParts = name.split(/[\s\-–—:,]+/).filter(Boolean); for (var i = 0; i < nameParts.length; i++) { if (nameParts[i].startsWith(q)) { s += 30; break; } } // Last resort: single word anywhere in keywords or summary if (s === 0 && keywords.indexOf(q) !== -1) s += 18; if (s === 0 && summary.indexOf(q) !== -1) s += 12; } return s; } function search(query) { var q = query.trim().toLowerCase(); if (!q) return []; var items = buildIndex(); var results = []; for (var i = 0; i < items.length; i++) { var s = scoreItem(items[i], q); if (s > 0) results.push({ item: items[i], s: s }); } results.sort(function (a, b) { return b.s - a.s; }); return results.slice(0, MAX_RESULTS).map(function (r) { return r.item; }); } // ── Utilities ──────────────────────────────────────────────────────── function escHtml(str) { var d = document.createElement('div'); d.textContent = (str == null) ? '' : String(str); return d.innerHTML; } /** * Highlight the first occurrence of `query` (or its first matching word) * inside `text`. Returns an HTML-safe string with a around the match. */ function highlight(text, query) { if (!text) return ''; if (!query) return escHtml(text); var lower = text.toLowerCase(); var q = query.trim().toLowerCase(); var idx = lower.indexOf(q); var matchLen = q.length; if (idx === -1) { // Try each word individually var words = q.split(/\s+/).filter(Boolean); for (var i = 0; i < words.length; i++) { idx = lower.indexOf(words[i]); if (idx !== -1) { matchLen = words[i].length; break; } } } if (idx === -1) return escHtml(text); return ( escHtml(text.slice(0, idx)) + '' + escHtml(text.slice(idx, idx + matchLen)) + '' + escHtml(text.slice(idx + matchLen)) ); } function truncate(str, max) { if (!str || str.length <= max) return str || ''; var cut = str.slice(0, max).replace(/\s+\S*$/, ''); return (cut.length > max * 0.6 ? cut : str.slice(0, max)) + '…'; } // ── Palette DOM (created lazily on first open) ──────────────────────── function createPaletteDOM() { if (document.getElementById(PALETTE_ID)) return; // Detect platform for the footer shortcut hint var isMac = /Mac|iPhone|iPod|iPad/.test( (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '' ); var shortcutLabel = isMac ? '⌘K' : 'Ctrl+K'; var el = document.createElement('div'); el.id = PALETTE_ID; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', 'Search lessons and glossary'); el.innerHTML = '
' + '
' + '
' + '' + '' + 'Esc' + '
' + '' + '' + '
'; document.body.appendChild(el); // Wire up internal interactions document.getElementById('cpBackdrop').addEventListener('click', close); document.getElementById('cpKbdEsc').addEventListener('click', close); var inp = document.getElementById('cpInput'); inp.addEventListener('input', _onInput); inp.addEventListener('keydown', _onKeyDown); } function _palEl() { return document.getElementById(PALETTE_ID); } function _inputEl() { return document.getElementById('cpInput'); } function _listEl() { return document.getElementById('cpResults'); } // ── Open / close ───────────────────────────────────────────────────── function open() { if (_isOpen) { // Already open — make sure the input is focused var inp = _inputEl(); if (inp) inp.focus(); return; } _prevFocus = document.activeElement || null; _isOpen = true; _activeIdx = -1; createPaletteDOM(); document.body.setAttribute(BODY_ATTR, ''); // Two-frame delay: first frame triggers transition, second ensures focus requestAnimationFrame(function () { var pal = _palEl(); if (pal) pal.classList.add('cp-open'); requestAnimationFrame(function () { var inp = _inputEl(); if (inp) { inp.focus(); var q = inp.value.trim(); renderResults(q ? search(q) : []); } }); }); } function close() { if (!_isOpen) return; _isOpen = false; _activeIdx = -1; var pal = _palEl(); if (pal) pal.classList.remove('cp-open'); document.body.removeAttribute(BODY_ATTR); // Return focus to wherever the user was before try { if (_prevFocus && typeof _prevFocus.focus === 'function') { _prevFocus.focus(); } } catch (_) { /* element may have been removed from DOM */ } _prevFocus = null; } // ── Render results ─────────────────────────────────────────────────── function renderResults(results) { var list = _listEl(); if (!list) return; var query = (_inputEl() ? _inputEl().value : '').trim(); if (!query) { list.innerHTML = '
  • ' + 'Type to search 503 lessons, 499 outputs, and glossary terms' + '
  • '; _activeIdx = -1; return; } if (results.length === 0) { list.innerHTML = '
  • ' + 'No results for ' + escHtml(query) + '' + '
  • '; _activeIdx = -1; return; } var html = ''; for (var i = 0; i < results.length; i++) { var r = results[i]; var dest = ''; var chip = ''; var chipClass = 'cp-item-chip'; if (r.kind === 'lesson') { // Prefer the in-site reader; fall back to GitHub URL dest = r.lessonPath ? 'lesson.html?path=' + encodeURIComponent(r.lessonPath) : r.url; chip = 'Phase ' + String(r.phaseId).padStart(2, '0'); } else if (r.kind === 'artifact') { // Jump to the lesson that produced this artifact dest = r.lessonPath ? 'lesson.html?path=' + encodeURIComponent(r.lessonPath) : ('https://github.com/rohitg00/ai-engineering-from-scratch/tree/main/' + r.file); var ak = (r.artKind || 'artifact'); chip = ak.charAt(0).toUpperCase() + ak.slice(1); chipClass += ' cp-item-chip--alt'; } else { // Deep-link: pre-populate glossary search with the exact term name // so the user lands directly on the definition, not the full list. dest = 'glossary.html?q=' + encodeURIComponent(r.name); chip = 'Glossary'; chipClass += ' cp-item-chip--alt'; } var snippet = r.summary ? truncate(r.summary, 110) : ''; var metaParts = []; if (r.kind === 'lesson') { if (r.type && r.type !== '—') metaParts.push(r.type); if (r.lang && r.lang !== '—') metaParts.push(r.lang); } else if (r.kind === 'artifact') { if (r.phaseId !== undefined && r.phaseId !== null) { metaParts.push('Phase ' + String(r.phaseId).padStart(2, '0')); } } var meta = metaParts.join(' · '); // · html += '
  • ' + '
    ' + '' + escHtml(chip) + '' + '' + highlight(r.name, query) + '' + (snippet ? '' + highlight(snippet, query) + '' : '') + (meta ? '' + escHtml(meta) + '' : '') + '
    ' + '' + '
  • '; } list.innerHTML = html; _activeIdx = -1; // Attach interaction handlers var items = list.querySelectorAll('.cp-item'); for (var j = 0; j < items.length; j++) { items[j].addEventListener('click', _onItemClick); items[j].addEventListener('mousemove', _onItemMouseMove); } } // ── Event handlers ─────────────────────────────────────────────────── function _onInput(e) { var query = e.target.value; renderResults(search(query)); _activeIdx = -1; } function _onKeyDown(e) { var list = _listEl(); var items = list ? list.querySelectorAll('.cp-item') : []; var count = items.length; switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!count) return; _activeIdx = (_activeIdx + 1) % count; _updateActive(items); break; case 'ArrowUp': e.preventDefault(); if (!count) return; _activeIdx = (_activeIdx - 1 + count) % count; _updateActive(items); break; case 'Enter': { e.preventDefault(); const target = (_activeIdx >= 0 && items[_activeIdx]) ? items[_activeIdx] : (count === 1 ? items[0] : null); if (target) _navigate(target); break; } case 'Tab': // Trap focus inside the palette (only interactive element is the input) e.preventDefault(); break; case 'Escape': e.preventDefault(); close(); break; } } function _updateActive(items) { for (var i = 0; i < items.length; i++) { var active = (i === _activeIdx); items[i].classList.toggle('cp-item--active', active); items[i].setAttribute('aria-selected', active ? 'true' : 'false'); if (active) items[i].scrollIntoView({ block: 'nearest' }); } } function _onItemClick(e) { _navigate(e.currentTarget); } function _onItemMouseMove(e) { var list = _listEl(); if (!list) return; var idx = parseInt(e.currentTarget.getAttribute('data-idx'), 10); if (idx !== _activeIdx) { _activeIdx = idx; _updateActive(list.querySelectorAll('.cp-item')); } } function _navigate(item) { var href = item.getAttribute('data-href'); if (!href) return; close(); window.location.href = href; } // ── Global keyboard shortcut (Cmd/Ctrl+K) ──────────────────────────── document.addEventListener('keydown', function (e) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); if (_isOpen) { // Palette is already open — just refocus the input var inp = _inputEl(); if (inp) inp.focus(); } else { open(); } } }); // ── Init: wire trigger buttons + eagerly build index ───────────────── function _init() { // Any element with [data-cmd-palette] opens the palette on click var triggers = document.querySelectorAll('[data-cmd-palette]'); for (var i = 0; i < triggers.length; i++) { triggers[i].addEventListener('click', function (e) { e.preventDefault(); open(); }); } // Build the search index now so the first keystroke is instant buildIndex(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', _init); } else { _init(); } // ── Public API ──────────────────────────────────────────────────────── window.CmdPalette = { open: open, close: close }; }());