Skip to content

Commit f53a617

Browse files
Add comprehensive full-text search with ranked results
- Builds search index from all pages (title, subtitle, body text) and all concepts (name, summary, definition) - Multi-term ranked scoring: exact title match (100pts), title contains (50pts), body contains (10pts), fuzzy match (5pts) - Results dropdown with highlighted snippets, icons, and metadata - Keyboard shortcuts: Ctrl+K/Cmd+K to focus, Esc to dismiss - Click result to navigate to page or concept's parent article - Supports both EN and CN content based on current language
1 parent 0ad02c6 commit f53a617

2 files changed

Lines changed: 303 additions & 1 deletion

File tree

‎site/app.js‎

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
<button class="lang-btn${lang === 'en' ? ' active' : ''}" data-lang="en">EN</button>
7171
<button class="lang-btn${lang === 'cn' ? ' active' : ''}" data-lang="cn">中文</button>
7272
</div>
73-
<input type="search" class="nav-search" placeholder="${lang === 'cn' ? '搜索页面...' : 'Search pages...'}" id="nav-search-input" />
73+
<input type="search" class="nav-search" placeholder="${lang === 'cn' ? '搜索 (Ctrl+K)...' : 'Search (Ctrl+K)...'}" id="nav-search-input" />
7474
<div class="nav-section-label">${lang === 'cn' ? '知识领域' : 'Knowledge Domains'}</div>
7575
<ul class="nav-tree">`;
7676

@@ -107,6 +107,7 @@
107107
const searchInput = document.getElementById("nav-search-input");
108108
searchInput.addEventListener("input", (e) => {
109109
const q = e.target.value.toLowerCase();
110+
// Filter nav items
110111
nav.querySelectorAll(".nav-page").forEach((el) => {
111112
const match = el.textContent.toLowerCase().includes(q);
112113
el.style.display = match || !q ? "" : "none";
@@ -115,6 +116,14 @@
115116
nav.querySelectorAll(".nav-children").forEach((el) => el.classList.add("open"));
116117
nav.querySelectorAll(".nav-domain").forEach((el) => el.classList.add("expanded"));
117118
}
119+
// Full-text search results dropdown
120+
showSearchResults(e.target.value);
121+
});
122+
searchInput.addEventListener("focus", () => {
123+
if (searchInput.value.trim()) showSearchResults(searchInput.value);
124+
});
125+
searchInput.addEventListener("blur", (e) => {
126+
setTimeout(() => removeSearchDropdown(), 200);
118127
});
119128
}
120129

@@ -562,6 +571,210 @@
562571
});
563572
}
564573

574+
// === Full-text Search Engine ===
575+
576+
function buildSearchIndex() {
577+
const index = [];
578+
579+
for (const [id, page] of Object.entries(WIKI.pages)) {
580+
if (page.type === "index") continue;
581+
const title = t("pages", id, "title") || page.title;
582+
const subtitle = t("pages", id, "subtitle") || page.subtitle || "";
583+
const body = t("pages", id, "body") || page.body || "";
584+
const plainBody = body.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
585+
586+
index.push({
587+
type: "page",
588+
id: id,
589+
title: title,
590+
text: `${title} ${subtitle} ${plainBody}`,
591+
subtitle: subtitle,
592+
domain: page.domain || ""
593+
});
594+
}
595+
596+
if (WIKI.concepts) {
597+
for (const [id, concept] of Object.entries(WIKI.concepts)) {
598+
const name = t("concepts", id, "name") || concept.name;
599+
const summary = t("concepts", id, "summary") || concept.summary || "";
600+
const definition = t("concepts", id, "definition") || concept.definition || "";
601+
const plainDef = definition.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
602+
603+
index.push({
604+
type: "concept",
605+
id: id,
606+
title: name,
607+
text: `${name} ${summary} ${plainDef}`,
608+
subtitle: summary,
609+
pageLinks: concept.usedIn || []
610+
});
611+
}
612+
}
613+
614+
return index;
615+
}
616+
617+
function searchQuery(query, index) {
618+
if (!query.trim()) return [];
619+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
620+
const results = [];
621+
622+
for (const entry of index) {
623+
const text = entry.text.toLowerCase();
624+
let score = 0;
625+
let allMatch = true;
626+
627+
for (const term of terms) {
628+
const titleLower = entry.title.toLowerCase();
629+
if (titleLower === term) {
630+
score += 100;
631+
} else if (titleLower.includes(term)) {
632+
score += 50;
633+
} else if (text.includes(term)) {
634+
score += 10;
635+
} else {
636+
// Fuzzy: allow 1 character difference for terms > 3 chars
637+
if (term.length > 3 && fuzzyMatch(text, term)) {
638+
score += 5;
639+
} else {
640+
allMatch = false;
641+
}
642+
}
643+
}
644+
645+
if (allMatch && score > 0) {
646+
results.push({ ...entry, score });
647+
}
648+
}
649+
650+
results.sort((a, b) => b.score - a.score);
651+
return results.slice(0, 20);
652+
}
653+
654+
function fuzzyMatch(text, term) {
655+
for (let i = 0; i <= text.length - term.length; i++) {
656+
let diffs = 0;
657+
for (let j = 0; j < term.length; j++) {
658+
if (text[i + j] !== term[j]) diffs++;
659+
if (diffs > 1) break;
660+
}
661+
if (diffs <= 1) return true;
662+
}
663+
return false;
664+
}
665+
666+
function getSnippet(text, query, maxLen = 120) {
667+
const plain = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
668+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
669+
const lower = plain.toLowerCase();
670+
671+
let bestPos = 0;
672+
for (const term of terms) {
673+
const idx = lower.indexOf(term);
674+
if (idx !== -1) { bestPos = idx; break; }
675+
}
676+
677+
const start = Math.max(0, bestPos - 30);
678+
const end = Math.min(plain.length, start + maxLen);
679+
let snippet = plain.slice(start, end).trim();
680+
if (start > 0) snippet = "..." + snippet;
681+
if (end < plain.length) snippet += "...";
682+
683+
// Highlight matching terms in snippet
684+
for (const term of terms) {
685+
const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
686+
snippet = snippet.replace(regex, "<mark>$1</mark>");
687+
}
688+
689+
return snippet;
690+
}
691+
692+
let searchIndex = null;
693+
let searchDropdown = null;
694+
695+
function initSearch() {
696+
searchIndex = buildSearchIndex();
697+
}
698+
699+
function showSearchResults(query) {
700+
if (!searchIndex) initSearch();
701+
removeSearchDropdown();
702+
703+
if (!query.trim()) return;
704+
705+
const results = searchQuery(query, searchIndex);
706+
if (results.length === 0) {
707+
const dd = createSearchDropdown();
708+
dd.innerHTML = `<div class="search-empty">${lang === 'cn' ? '未找到结果' : 'No results found'}</div>`;
709+
return;
710+
}
711+
712+
const dd = createSearchDropdown();
713+
let html = "";
714+
for (const r of results) {
715+
const icon = r.type === "page" ? "📄" : "💡";
716+
const snippet = getSnippet(r.text, query);
717+
const meta = r.type === "page" ? r.domain : (lang === 'cn' ? '概念' : 'Concept');
718+
html += `<div class="search-result" data-type="${r.type}" data-id="${r.id}">`;
719+
html += `<div class="search-result-header">`;
720+
html += `<span class="search-result-icon">${icon}</span>`;
721+
html += `<span class="search-result-title">${r.title}</span>`;
722+
html += `<span class="search-result-meta">${meta}</span>`;
723+
html += `</div>`;
724+
html += `<div class="search-result-snippet">${snippet}</div>`;
725+
html += `</div>`;
726+
}
727+
dd.innerHTML = html;
728+
729+
dd.querySelectorAll(".search-result").forEach((el) => {
730+
el.addEventListener("click", () => {
731+
const type = el.dataset.type;
732+
const id = el.dataset.id;
733+
removeSearchDropdown();
734+
document.getElementById("nav-search-input").value = "";
735+
if (type === "page") {
736+
navigate(id);
737+
} else {
738+
// For concepts, navigate to the first page that uses it
739+
const concept = WIKI.concepts?.[id];
740+
if (concept?.usedIn?.length > 0) {
741+
navigate(concept.usedIn[0]);
742+
}
743+
}
744+
});
745+
});
746+
}
747+
748+
function createSearchDropdown() {
749+
removeSearchDropdown();
750+
const dd = document.createElement("div");
751+
dd.className = "search-dropdown";
752+
dd.id = "search-dropdown";
753+
nav.appendChild(dd);
754+
searchDropdown = dd;
755+
return dd;
756+
}
757+
758+
function removeSearchDropdown() {
759+
const existing = document.getElementById("search-dropdown");
760+
if (existing) existing.remove();
761+
searchDropdown = null;
762+
}
763+
764+
// Global keyboard shortcuts
765+
document.addEventListener("keydown", (e) => {
766+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
767+
e.preventDefault();
768+
const input = document.getElementById("nav-search-input");
769+
if (input) { input.focus(); input.select(); }
770+
}
771+
if (e.key === "Escape") {
772+
removeSearchDropdown();
773+
const input = document.getElementById("nav-search-input");
774+
if (input) input.blur();
775+
}
776+
});
777+
565778
// === Init ===
566779
navigate("index");
567780
})();

‎site/styles.css‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,95 @@ body {
190190
background: var(--c-surface);
191191
}
192192

193+
/* Search Results Dropdown */
194+
.search-dropdown {
195+
position: absolute;
196+
left: var(--space-md);
197+
right: var(--space-md);
198+
top: auto;
199+
max-height: 400px;
200+
overflow-y: auto;
201+
background: var(--c-surface);
202+
border: 1px solid var(--c-border);
203+
border-radius: var(--radius-lg);
204+
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
205+
z-index: 1000;
206+
padding: var(--space-xs);
207+
margin-top: var(--space-xs);
208+
}
209+
210+
.search-empty {
211+
padding: var(--space-lg);
212+
text-align: center;
213+
color: var(--c-text-tertiary);
214+
font-size: 0.8125rem;
215+
font-style: italic;
216+
}
217+
218+
.search-result {
219+
padding: var(--space-sm) var(--space-md);
220+
border-radius: var(--radius);
221+
cursor: pointer;
222+
transition: background 0.1s;
223+
}
224+
225+
.search-result:hover {
226+
background: var(--c-nav-hover);
227+
}
228+
229+
.search-result + .search-result {
230+
border-top: 1px solid var(--c-border);
231+
}
232+
233+
.search-result-header {
234+
display: flex;
235+
align-items: center;
236+
gap: var(--space-xs);
237+
margin-bottom: 2px;
238+
}
239+
240+
.search-result-icon {
241+
font-size: 0.75rem;
242+
flex-shrink: 0;
243+
}
244+
245+
.search-result-title {
246+
font-family: var(--font-ui);
247+
font-size: 0.8125rem;
248+
font-weight: 600;
249+
color: var(--c-text);
250+
white-space: nowrap;
251+
overflow: hidden;
252+
text-overflow: ellipsis;
253+
}
254+
255+
.search-result-meta {
256+
font-size: 0.625rem;
257+
color: var(--c-text-tertiary);
258+
font-weight: 500;
259+
text-transform: uppercase;
260+
letter-spacing: 0.04em;
261+
margin-left: auto;
262+
flex-shrink: 0;
263+
}
264+
265+
.search-result-snippet {
266+
font-size: 0.75rem;
267+
color: var(--c-text-secondary);
268+
line-height: 1.4;
269+
overflow: hidden;
270+
display: -webkit-box;
271+
-webkit-line-clamp: 2;
272+
-webkit-box-orient: vertical;
273+
}
274+
275+
.search-result-snippet mark {
276+
background: #fef08a;
277+
color: inherit;
278+
border-radius: 2px;
279+
padding: 0 1px;
280+
}
281+
193282
.nav-section-label {
194283
font-size: 0.6875rem;
195284
font-weight: 600;

0 commit comments

Comments
 (0)