mirror of
https://github.com/NotAShelf/nvf.git
synced 2026-02-04 19:05:55 +00:00
1439 lines
42 KiB
JavaScript
1439 lines
42 KiB
JavaScript
if (!window.searchNamespace) window.searchNamespace = {};
|
|
|
|
class SearchEngine {
|
|
constructor() {
|
|
this.documents = [];
|
|
this.tokenMap = new Map();
|
|
this.isLoaded = false;
|
|
this.loadError = false;
|
|
this.fullDocuments = null; // for lazy loading
|
|
this.rootPath = window.searchNamespace?.rootPath || "";
|
|
}
|
|
|
|
// Check if we can use Web Worker
|
|
get useWebWorker() {
|
|
if (searchWorker === false) return false; // previously failed
|
|
const worker = initializeSearchWorker();
|
|
return worker !== null;
|
|
}
|
|
|
|
// Load search data from JSON
|
|
async loadData() {
|
|
if (this.isLoaded && !this.loadError) return;
|
|
|
|
// Clear previous error state on retry
|
|
this.loadError = false;
|
|
|
|
try {
|
|
// Load JSON data, try multiple possible paths
|
|
// FIXME: There is only one possible path for now, and this search data is guaranteed
|
|
// to generate at this location, but we'll want to extend this in the future.
|
|
const possiblePaths = [
|
|
`${this.rootPath}assets/search-data.json`,
|
|
"/assets/search-data.json", // fallback for root-level sites
|
|
];
|
|
|
|
let response = null;
|
|
let usedPath = "";
|
|
|
|
for (const path of possiblePaths) {
|
|
try {
|
|
const testResponse = await fetch(path);
|
|
if (testResponse.ok) {
|
|
response = testResponse;
|
|
usedPath = path;
|
|
break;
|
|
}
|
|
} catch {
|
|
// Continue to next path
|
|
}
|
|
}
|
|
|
|
if (!response) {
|
|
throw new Error("Search data file not found at any expected location");
|
|
}
|
|
|
|
console.log(`Loading search data from: ${usedPath}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const documents = await response.json();
|
|
if (!Array.isArray(documents)) {
|
|
throw new Error("Invalid search data format");
|
|
}
|
|
|
|
this.initializeFromDocuments(documents);
|
|
this.isLoaded = true;
|
|
console.log(`Loaded ${documents.length} documents for search`);
|
|
} catch (error) {
|
|
console.error("Error loading search data:", error);
|
|
this.documents = [];
|
|
this.tokenMap.clear();
|
|
this.loadError = true;
|
|
}
|
|
}
|
|
|
|
// Initialize from documents array
|
|
async initializeFromDocuments(documents) {
|
|
if (!Array.isArray(documents)) {
|
|
console.error("Invalid documents format:", typeof documents);
|
|
this.documents = [];
|
|
} else {
|
|
this.documents = documents;
|
|
console.log(`Initialized with ${documents.length} documents`);
|
|
}
|
|
try {
|
|
await this.buildTokenMap();
|
|
} catch (error) {
|
|
console.error("Error building token map:", error);
|
|
}
|
|
}
|
|
|
|
// Initialize from search index structure
|
|
initializeIndex(indexData) {
|
|
this.documents = indexData.documents || [];
|
|
this.tokenMap = new Map(Object.entries(indexData.tokenMap || {}));
|
|
}
|
|
|
|
// Build token map
|
|
// This is helpful for faster searching with progressive loading
|
|
buildTokenMap() {
|
|
return new Promise((resolve, reject) => {
|
|
this.tokenMap.clear();
|
|
|
|
if (!Array.isArray(this.documents)) {
|
|
console.error("No documents to build token map");
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const totalDocs = this.documents.length;
|
|
let processedDocs = 0;
|
|
|
|
try {
|
|
// Process in chunks to avoid blocking UI
|
|
const processChunk = (startIndex, chunkSize) => {
|
|
try {
|
|
const endIndex = Math.min(startIndex + chunkSize, totalDocs);
|
|
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const doc = this.documents[i];
|
|
if (
|
|
!doc ||
|
|
typeof doc.title !== "string" ||
|
|
typeof doc.content !== "string"
|
|
) {
|
|
console.warn(`Invalid document at index ${i}:`, doc);
|
|
continue;
|
|
}
|
|
|
|
const tokens = this.tokenize(doc.title + " " + doc.content);
|
|
tokens.forEach((token) => {
|
|
if (!this.tokenMap.has(token)) {
|
|
this.tokenMap.set(token, []);
|
|
}
|
|
this.tokenMap.get(token).push(i);
|
|
});
|
|
|
|
processedDocs++;
|
|
}
|
|
|
|
// Update progress and yield control
|
|
if (endIndex < totalDocs) {
|
|
setTimeout(() => processChunk(endIndex, chunkSize), 0);
|
|
} else {
|
|
console.log(
|
|
`Built token map with ${this.tokenMap.size} unique tokens from ${processedDocs} documents`,
|
|
);
|
|
resolve();
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
// Start processing with small chunks
|
|
processChunk(0, 100);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
isWordBoundary(char) {
|
|
return /[A-Z]/.test(char) || /[-_\/.]/.test(char) || /\s/.test(char);
|
|
}
|
|
|
|
isCaseTransition(prev, curr) {
|
|
const prevIsUpper = prev.toLowerCase() !== prev;
|
|
const currIsUpper = curr.toLowerCase() !== curr;
|
|
return (
|
|
prevIsUpper && currIsUpper && prev.toLowerCase() !== curr.toLowerCase()
|
|
);
|
|
}
|
|
|
|
fuzzyMatch(query, target) {
|
|
const lowerQuery = query.toLowerCase();
|
|
const lowerTarget = target.toLowerCase();
|
|
|
|
if (lowerQuery.length === 0) return null;
|
|
if (lowerTarget.length === 0) return null;
|
|
|
|
if (lowerTarget === lowerQuery) {
|
|
return 1.0;
|
|
}
|
|
|
|
if (lowerTarget.includes(lowerQuery)) {
|
|
const ratio = lowerQuery.length / lowerTarget.length;
|
|
return 0.8 + ratio * 0.2;
|
|
}
|
|
|
|
const matches = this.findBestSubsequenceMatch(lowerQuery, lowerTarget);
|
|
if (!matches) {
|
|
return null;
|
|
}
|
|
|
|
return Math.min(1.0, matches.score);
|
|
}
|
|
|
|
findBestSubsequenceMatch(query, target) {
|
|
const n = query.length;
|
|
const m = target.length;
|
|
|
|
if (n === 0 || m === 0) return null;
|
|
|
|
const positions = [];
|
|
|
|
const memo = new Map();
|
|
const key = (qIdx, tIdx) => `${qIdx}:${tIdx}`;
|
|
|
|
const findBest = (qIdx, tIdx, currentGap) => {
|
|
if (qIdx === n) {
|
|
return { done: true, positions: [...positions], gap: currentGap };
|
|
}
|
|
|
|
const memoKey = key(qIdx, tIdx);
|
|
if (memo.has(memoKey)) {
|
|
return memo.get(memoKey);
|
|
}
|
|
|
|
let bestResult = null;
|
|
|
|
for (let i = tIdx; i < m; i++) {
|
|
if (target[i] === query[qIdx]) {
|
|
positions.push(i);
|
|
const gap = qIdx === 0 ? 0 : i - positions[positions.length - 2] - 1;
|
|
const newGap = currentGap + gap;
|
|
|
|
if (newGap > m) {
|
|
positions.pop();
|
|
continue;
|
|
}
|
|
|
|
const result = findBest(qIdx + 1, i + 1, newGap);
|
|
positions.pop();
|
|
|
|
if (result && (!bestResult || result.gap < bestResult.gap)) {
|
|
bestResult = result;
|
|
if (result.gap === 0) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
memo.set(memoKey, bestResult);
|
|
return bestResult;
|
|
};
|
|
|
|
const result = findBest(0, 0, 0);
|
|
if (!result) return null;
|
|
|
|
const consecutive = (() => {
|
|
let c = 1;
|
|
for (let i = 1; i < result.positions.length; i++) {
|
|
if (result.positions[i] === result.positions[i - 1] + 1) {
|
|
c++;
|
|
}
|
|
}
|
|
return c;
|
|
})();
|
|
|
|
return {
|
|
positions: result.positions,
|
|
consecutive,
|
|
score: this.calculateMatchScore(
|
|
query,
|
|
target,
|
|
result.positions,
|
|
consecutive,
|
|
),
|
|
};
|
|
}
|
|
|
|
calculateMatchScore(query, target, positions, consecutive) {
|
|
const n = positions.length;
|
|
const m = target.length;
|
|
|
|
if (n === 0) return 0;
|
|
|
|
let score = 1.0;
|
|
|
|
const startBonus = (m - positions[0]) / m;
|
|
score += startBonus * 0.5;
|
|
|
|
let gapPenalty = 0;
|
|
for (let i = 1; i < n; i++) {
|
|
const gap = positions[i] - positions[i - 1] - 1;
|
|
if (gap > 0) {
|
|
gapPenalty += Math.min(gap / m, 1.0) * 0.3;
|
|
}
|
|
}
|
|
score -= gapPenalty;
|
|
|
|
const consecutiveBonus = consecutive / n;
|
|
score += consecutiveBonus * 0.3;
|
|
|
|
let boundaryBonus = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
const char = target[positions[i]];
|
|
if (i === 0 || this.isWordBoundary(char)) {
|
|
boundaryBonus += 0.05;
|
|
}
|
|
if (i > 0) {
|
|
const prevChar = target[positions[i - 1]];
|
|
if (this.isCaseTransition(prevChar, char)) {
|
|
boundaryBonus += 0.03;
|
|
}
|
|
}
|
|
}
|
|
score = Math.min(1.0, score + boundaryBonus);
|
|
|
|
const lengthPenalty = Math.abs(query.length - n) /
|
|
Math.max(query.length, m);
|
|
score -= lengthPenalty * 0.2;
|
|
|
|
return Math.max(0, Math.min(1.0, score));
|
|
}
|
|
|
|
tokenize(text) {
|
|
if (!text || typeof text !== "string") return [];
|
|
|
|
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
|
|
const tokens = words.filter((word) => word.length > 2);
|
|
return Array.from(new Set(tokens));
|
|
}
|
|
|
|
// Advanced search with ranking
|
|
async search(query, limit = 10, options = {}) {
|
|
if (!query || typeof query !== "string" || !query.trim()) {
|
|
return [];
|
|
}
|
|
|
|
if (options.signal?.aborted) {
|
|
return [];
|
|
}
|
|
|
|
// Wait for data to be loaded
|
|
if (!this.isLoaded) {
|
|
await this.loadData();
|
|
}
|
|
|
|
if (options.signal?.aborted) {
|
|
return [];
|
|
}
|
|
|
|
if (!this.isLoaded || this.documents.length === 0) {
|
|
console.log("Search data not available");
|
|
return [];
|
|
}
|
|
|
|
const searchTerms = this.tokenize(query);
|
|
const rawQuery = query.toLowerCase();
|
|
|
|
// Require at least 2 characters for search
|
|
if (searchTerms.length === 0 && rawQuery.length < 2) {
|
|
return [];
|
|
}
|
|
|
|
const useFuzzySearch = rawQuery.length >= 3;
|
|
|
|
const pageMatches = new Map();
|
|
const totalDocs = this.documents.length;
|
|
let lastCheckTime = Date.now();
|
|
const CHECK_INTERVAL = 16; // Check every ~16ms (one frame)
|
|
|
|
for (let docIdx = 0; docIdx < totalDocs; docIdx++) {
|
|
// Check for abort periodically
|
|
if (Date.now() - lastCheckTime > CHECK_INTERVAL) {
|
|
if (options.signal?.aborted) {
|
|
return [];
|
|
}
|
|
// Yield to main thread
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
lastCheckTime = Date.now();
|
|
|
|
if (options.signal?.aborted) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const doc = this.documents[docIdx];
|
|
let match = pageMatches.get(docIdx);
|
|
if (!match) {
|
|
match = { doc, pageScore: 0, matchingAnchors: [] };
|
|
pageMatches.set(docIdx, match);
|
|
}
|
|
|
|
const lowerTitle = (
|
|
typeof doc.title === "string" ? doc.title : ""
|
|
).toLowerCase();
|
|
const lowerContent = (
|
|
typeof doc.content === "string" ? doc.content : ""
|
|
).toLowerCase();
|
|
|
|
if (useFuzzySearch) {
|
|
const fuzzyTitleScore = this.fuzzyMatch(rawQuery, lowerTitle);
|
|
|
|
if (fuzzyTitleScore !== null) {
|
|
match.pageScore += fuzzyTitleScore * 100;
|
|
}
|
|
|
|
const fuzzyContentScore = this.fuzzyMatch(rawQuery, lowerContent);
|
|
|
|
if (fuzzyContentScore !== null) {
|
|
match.pageScore += fuzzyContentScore * 30;
|
|
}
|
|
}
|
|
|
|
searchTerms.forEach((term) => {
|
|
if (lowerTitle.includes(term)) {
|
|
match.pageScore += lowerTitle === term ? 20 : 10;
|
|
}
|
|
if (lowerContent.includes(term)) {
|
|
match.pageScore += 2;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (options.signal?.aborted) {
|
|
return [];
|
|
}
|
|
|
|
pageMatches.forEach((match) => {
|
|
const doc = match.doc;
|
|
if (
|
|
!doc.anchors ||
|
|
!Array.isArray(doc.anchors) ||
|
|
doc.anchors.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const anchorSet = new Set();
|
|
|
|
// Check for anchor text matches
|
|
doc.anchors.forEach((anchor) => {
|
|
if (!anchor || !anchor.text) return;
|
|
|
|
const anchorText = anchor.text.toLowerCase();
|
|
let anchorMatches = false;
|
|
|
|
if (useFuzzySearch) {
|
|
const fuzzyScore = this.fuzzyMatch(rawQuery, anchorText);
|
|
if (fuzzyScore !== null && fuzzyScore >= 0.4) {
|
|
anchorMatches = true;
|
|
}
|
|
}
|
|
|
|
if (!anchorMatches) {
|
|
searchTerms.forEach((term) => {
|
|
if (anchorText.includes(term)) {
|
|
anchorMatches = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (anchorMatches) {
|
|
anchorSet.add(anchor.id);
|
|
}
|
|
});
|
|
|
|
// Check for content matches and find their containing sections
|
|
if (doc.content && typeof doc.content === "string") {
|
|
const lowerContent = doc.content.toLowerCase();
|
|
|
|
searchTerms.forEach((term) => {
|
|
let searchPos = 0;
|
|
let matchIndex;
|
|
|
|
while ((matchIndex = lowerContent.indexOf(term, searchPos)) !== -1) {
|
|
const containingAnchor = this.findContainingSection(
|
|
doc,
|
|
matchIndex,
|
|
);
|
|
if (containingAnchor && !anchorSet.has(containingAnchor.id)) {
|
|
anchorSet.add(containingAnchor.id);
|
|
}
|
|
searchPos = matchIndex + term.length;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert set back to anchor objects
|
|
doc.anchors.forEach((anchor) => {
|
|
if (anchorSet.has(anchor.id)) {
|
|
match.matchingAnchors.push(anchor);
|
|
}
|
|
});
|
|
});
|
|
|
|
const results = Array.from(pageMatches.values())
|
|
.filter((m) => m.pageScore > 5)
|
|
.sort((a, b) => b.pageScore - a.pageScore)
|
|
.slice(0, limit);
|
|
|
|
return results;
|
|
}
|
|
|
|
// Generate search preview with highlighting
|
|
generatePreview(content, query, maxLength = 200) {
|
|
if (!content || typeof content !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const lowerContent = content.toLowerCase();
|
|
const queryWords = this.tokenize(query);
|
|
|
|
// Find the best match position
|
|
let bestIndex = -1;
|
|
let bestMatch = "";
|
|
|
|
for (const word of queryWords) {
|
|
const index = lowerContent.indexOf(word);
|
|
if (index !== -1 && word.length > bestMatch.length) {
|
|
bestIndex = index;
|
|
bestMatch = word;
|
|
}
|
|
}
|
|
|
|
// If no match found, show beginning
|
|
if (bestIndex === -1) {
|
|
const preview = content.slice(0, maxLength).trim();
|
|
const escaped = this.escapeHtml(preview);
|
|
return escaped + (content.length > maxLength ? "..." : "");
|
|
}
|
|
|
|
// Find paragraph boundaries around the match
|
|
const paragraphs = content.split("\n").filter((p) => p.trim());
|
|
let currentPos = 0;
|
|
let matchParagraphIndex = -1;
|
|
|
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
const paragraphEnd = currentPos + paragraphs[i].length;
|
|
if (bestIndex >= currentPos && bestIndex < paragraphEnd) {
|
|
matchParagraphIndex = i;
|
|
break;
|
|
}
|
|
currentPos = paragraphEnd + 1;
|
|
}
|
|
|
|
if (matchParagraphIndex === -1) {
|
|
matchParagraphIndex = 0;
|
|
}
|
|
|
|
// If matching paragraph is very short (likely a title/heading),
|
|
// prefer showing the next paragraph if it also contains the search term
|
|
if (
|
|
matchParagraphIndex < paragraphs.length - 1 &&
|
|
paragraphs[matchParagraphIndex].length < 50
|
|
) {
|
|
const nextParagraph = paragraphs[matchParagraphIndex + 1];
|
|
if (nextParagraph.toLowerCase().includes(bestMatch)) {
|
|
matchParagraphIndex++;
|
|
}
|
|
}
|
|
|
|
// Get the matching paragraph
|
|
let preview = paragraphs[matchParagraphIndex];
|
|
|
|
// If paragraph is too long, extract context around match
|
|
if (preview.length > maxLength) {
|
|
const matchInParagraph = preview.toLowerCase().indexOf(bestMatch);
|
|
if (matchInParagraph !== -1) {
|
|
const contextBefore = 60;
|
|
const contextAfter = 100;
|
|
const start = Math.max(0, matchInParagraph - contextBefore);
|
|
const end = Math.min(
|
|
preview.length,
|
|
matchInParagraph + bestMatch.length + contextAfter,
|
|
);
|
|
preview = preview.slice(start, end).trim();
|
|
if (start > 0) preview = "..." + preview;
|
|
if (end < paragraphs[matchParagraphIndex].length) preview += "...";
|
|
} else {
|
|
preview = preview.slice(0, maxLength) + "...";
|
|
}
|
|
}
|
|
|
|
return this.escapeHtml(preview);
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
escapeHtml(text) {
|
|
if (!text || typeof text !== "string") return "";
|
|
|
|
const escapeMap = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
"/": "/",
|
|
};
|
|
|
|
return text.replace(/[&<>"'\/]/g, (char) => escapeMap[char]);
|
|
}
|
|
|
|
// Highlight search terms in text
|
|
highlightTerms(text, terms) {
|
|
if (!text || typeof text !== "string") return "";
|
|
if (!Array.isArray(terms) || terms.length === 0) {
|
|
return this.escapeHtml(text);
|
|
}
|
|
|
|
// Escape HTML first
|
|
let highlighted = this.escapeHtml(text);
|
|
|
|
// Sort terms by length (longer first) to avoid overlapping highlights
|
|
const sortedTerms = [...terms].sort((a, b) => b.length - a.length);
|
|
|
|
sortedTerms.forEach((term) => {
|
|
if (!term || typeof term !== "string") return;
|
|
const regex = new RegExp(`(${this.escapeRegex(term)})`, "gi");
|
|
highlighted = highlighted.replace(regex, "<mark>$1</mark>");
|
|
});
|
|
|
|
return highlighted;
|
|
}
|
|
|
|
/**
|
|
* Web Worker search for large datasets
|
|
* @param {string} query - Search query
|
|
* @param {number} limit - Maximum results
|
|
* @returns {Promise<Array>} Search results
|
|
*/
|
|
searchWithWorker(query, limit) {
|
|
const worker = initializeSearchWorker();
|
|
if (!worker) {
|
|
return this.fallbackSearch(query, limit);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const messageId = `search_${Date.now()}_${
|
|
Math.random().toString(36).substring(2, 11)
|
|
}`;
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error("Web Worker search timeout"));
|
|
}, 5000);
|
|
|
|
const handleMessage = (e) => {
|
|
if (e.data.messageId !== messageId) return;
|
|
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
|
|
if (e.data.type === "results") {
|
|
resolve(e.data.data);
|
|
} else if (e.data.type === "error") {
|
|
reject(new Error(e.data.error || "Unknown worker error"));
|
|
}
|
|
};
|
|
|
|
const handleError = (error) => {
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
worker.removeEventListener("message", handleMessage);
|
|
worker.removeEventListener("error", handleError);
|
|
};
|
|
|
|
worker.addEventListener("message", handleMessage);
|
|
worker.addEventListener("error", handleError);
|
|
|
|
worker.postMessage(
|
|
{
|
|
messageId,
|
|
type: "search",
|
|
data: { query, limit },
|
|
documents: this.documents,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
// Normalize text for comparison
|
|
normalizeForComparison(text) {
|
|
if (!text || typeof text !== "string") return "";
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.replace(/[.,!?;:'"…—–-]+$/g, "")
|
|
.trim();
|
|
}
|
|
|
|
// Find which section/heading a content match belongs to
|
|
findContainingSection(doc, matchIndex) {
|
|
if (!doc.content || !doc.anchors || doc.anchors.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const paragraphs = doc.content.split("\n").filter((p) => p.trim());
|
|
|
|
// Find which paragraph contains the match
|
|
let currentPos = 0;
|
|
let matchParagraphIndex = -1;
|
|
|
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
const paragraphEnd = currentPos + paragraphs[i].length;
|
|
if (matchIndex >= currentPos && matchIndex < paragraphEnd) {
|
|
matchParagraphIndex = i;
|
|
break;
|
|
}
|
|
currentPos = paragraphEnd + 1;
|
|
}
|
|
|
|
if (matchParagraphIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
// Find the last heading that appears before this paragraph
|
|
let containingAnchor = null;
|
|
|
|
for (let i = 0; i <= matchParagraphIndex; i++) {
|
|
const para = paragraphs[i].trim();
|
|
const matchingAnchor = doc.anchors.find((a) => {
|
|
const normalizedAnchor = this.normalizeForComparison(a.text);
|
|
const normalizedPara = this.normalizeForComparison(para);
|
|
return normalizedAnchor === normalizedPara;
|
|
});
|
|
|
|
if (matchingAnchor) {
|
|
containingAnchor = matchingAnchor;
|
|
}
|
|
}
|
|
|
|
return containingAnchor;
|
|
}
|
|
|
|
// Generate preview for a specific section
|
|
generateSectionPreview(doc, anchor, query, maxLength = 200) {
|
|
if (!doc.content || !anchor) {
|
|
return "";
|
|
}
|
|
|
|
const paragraphs = doc.content.split("\n").filter((p) => p.trim());
|
|
|
|
// Find where this section starts and ends
|
|
let sectionStart = -1;
|
|
let sectionEnd = paragraphs.length;
|
|
|
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
const para = paragraphs[i].trim();
|
|
const normalizedPara = this.normalizeForComparison(para);
|
|
const normalizedAnchor = this.normalizeForComparison(anchor.text);
|
|
|
|
if (normalizedPara === normalizedAnchor) {
|
|
sectionStart = i;
|
|
} else if (sectionStart !== -1 && doc.anchors) {
|
|
// Check if this is another heading
|
|
const isHeading = doc.anchors.some((a) => {
|
|
const norm = this.normalizeForComparison(a.text);
|
|
return norm === normalizedPara;
|
|
});
|
|
|
|
if (isHeading) {
|
|
sectionEnd = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sectionStart === -1) {
|
|
return "";
|
|
}
|
|
|
|
// Get content of this section (excluding the heading itself)
|
|
const sectionParagraphs = paragraphs.slice(sectionStart + 1, sectionEnd);
|
|
const sectionContent = sectionParagraphs.join("\n");
|
|
|
|
// Use existing generatePreview on just this section's content
|
|
return this.generatePreview(sectionContent, query, maxLength);
|
|
}
|
|
|
|
// Escape regex special characters
|
|
escapeRegex(string) {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// Resolve path relative to current page location
|
|
resolvePath(path) {
|
|
// If path already starts with '/', it's absolute from domain root
|
|
if (path.startsWith("/")) {
|
|
return path;
|
|
}
|
|
|
|
// If path starts with '#', it's a fragment on current page
|
|
if (path.startsWith("#")) {
|
|
return path;
|
|
}
|
|
|
|
// Prepend root path for relative navigation
|
|
return this.rootPath + path;
|
|
}
|
|
|
|
// Lazy loading for search results
|
|
lazyLoadDocuments(docIds, limit = 10) {
|
|
if (!this.fullDocuments) {
|
|
// Store full documents separately for memory efficiency
|
|
this.fullDocuments = this.documents;
|
|
// Create lightweight index documents
|
|
this.documents = this.documents.map((doc) => ({
|
|
id: doc.id,
|
|
title: doc.title,
|
|
path: doc.path,
|
|
}));
|
|
}
|
|
|
|
return docIds.slice(0, limit).map((id) => this.fullDocuments[id]);
|
|
}
|
|
|
|
// Fallback search method via simple string matching
|
|
fallbackSearch(query, limit = 10) {
|
|
if (!query || typeof query !== "string") return [];
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
if (lowerQuery.length < 2) return [];
|
|
|
|
const results = this.documents
|
|
.map((doc) => {
|
|
if (!doc || !doc.title || !doc.content) {
|
|
return null;
|
|
}
|
|
|
|
const titleMatch = doc.title.toLowerCase().indexOf(lowerQuery);
|
|
const contentMatch = doc.content.toLowerCase().indexOf(lowerQuery);
|
|
let pageScore = 0;
|
|
|
|
if (titleMatch !== -1) {
|
|
pageScore += 10;
|
|
if (doc.title.toLowerCase() === lowerQuery) {
|
|
pageScore += 20;
|
|
}
|
|
}
|
|
if (contentMatch !== -1) {
|
|
pageScore += 2;
|
|
}
|
|
|
|
// Find matching anchors
|
|
const matchingAnchors = [];
|
|
if (
|
|
doc.anchors &&
|
|
Array.isArray(doc.anchors) &&
|
|
doc.anchors.length > 0
|
|
) {
|
|
doc.anchors.forEach((anchor) => {
|
|
if (!anchor || !anchor.text) return;
|
|
const anchorText = anchor.text.toLowerCase();
|
|
if (anchorText.includes(lowerQuery)) {
|
|
matchingAnchors.push(anchor);
|
|
}
|
|
});
|
|
}
|
|
|
|
return { doc, pageScore, matchingAnchors, titleMatch, contentMatch };
|
|
})
|
|
.filter((item) => item !== null && item.pageScore > 0)
|
|
.sort((a, b) => {
|
|
if (a.pageScore !== b.pageScore) return b.pageScore - a.pageScore;
|
|
if (a.titleMatch !== b.titleMatch) return a.titleMatch - b.titleMatch;
|
|
return a.contentMatch - b.contentMatch;
|
|
})
|
|
.slice(0, limit);
|
|
|
|
return results;
|
|
}
|
|
}
|
|
|
|
// Web Worker for background search processing
|
|
// Create Web Worker if supported - initialized lazily to use rootPath
|
|
let searchWorker = null;
|
|
|
|
function debounce(func, wait) {
|
|
let timeout = null;
|
|
return function (...args) {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
|
};
|
|
}
|
|
|
|
function initializeSearchWorker() {
|
|
if (searchWorker !== null || typeof Worker === "undefined") {
|
|
return searchWorker;
|
|
}
|
|
|
|
try {
|
|
const rootPath = window.searchNamespace?.rootPath || "";
|
|
const workerPath = rootPath
|
|
? `${rootPath}assets/search-worker.js`
|
|
: "/assets/search-worker.js";
|
|
searchWorker = new Worker(workerPath);
|
|
console.log("Web Worker initialized for background search");
|
|
return searchWorker;
|
|
} catch (error) {
|
|
console.warn("Web Worker creation failed, using main thread:", error);
|
|
searchWorker = false; // mark as failed so we don't retry
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Global search engine instance
|
|
window.searchNamespace.engine = new SearchEngine();
|
|
|
|
// Mobile search timeout for debouncing
|
|
let mobileSearchTimeout = null;
|
|
|
|
// AbortController for cancelling pending search requests
|
|
let searchPageController = null;
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
// Initialize search engine immediately
|
|
window.searchNamespace.engine
|
|
.loadData()
|
|
.then(() => {
|
|
console.log("Search data loaded successfully");
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to initialize search:", error);
|
|
});
|
|
|
|
// Search page specific functionality
|
|
const searchPageInput = document.getElementById("search-page-input");
|
|
if (searchPageInput) {
|
|
// Set up event listener with debouncing
|
|
searchPageInput.addEventListener(
|
|
"input",
|
|
debounce(function () {
|
|
const query = this.value.trim();
|
|
if (query.length >= 2) {
|
|
performSearch(query);
|
|
} else {
|
|
const resultsContainer = document.getElementById(
|
|
"search-page-results",
|
|
);
|
|
if (resultsContainer) {
|
|
resultsContainer.innerHTML =
|
|
"<p>Please enter at least 2 characters to search</p>";
|
|
}
|
|
}
|
|
}, 200),
|
|
);
|
|
|
|
// Perform search if URL has query
|
|
const params = new URLSearchParams(window.location.search);
|
|
const query = params.get("q");
|
|
if (query) {
|
|
searchPageInput.value = query;
|
|
performSearch(query);
|
|
}
|
|
}
|
|
|
|
// Desktop Sidebar Toggle
|
|
const searchInput = document.getElementById("search-input");
|
|
if (searchInput) {
|
|
const searchResults = document.getElementById("search-results");
|
|
const searchContainer = searchInput.closest(".search-container");
|
|
|
|
searchInput.addEventListener(
|
|
"input",
|
|
debounce(async function () {
|
|
const searchTerm = this.value.trim();
|
|
const currentSearchTerm = searchTerm;
|
|
|
|
if (searchTerm.length < 2) {
|
|
searchResults.innerHTML = "";
|
|
searchResults.style.display = "none";
|
|
if (searchContainer) searchContainer.classList.remove("has-results");
|
|
return;
|
|
}
|
|
|
|
searchResults.innerHTML =
|
|
'<div class="search-result-item">Loading...</div>';
|
|
searchResults.style.display = "block";
|
|
if (searchContainer) searchContainer.classList.add("has-results");
|
|
|
|
try {
|
|
const results = await window.searchNamespace.engine.search(
|
|
searchTerm,
|
|
8,
|
|
);
|
|
|
|
if (currentSearchTerm !== searchTerm) return;
|
|
|
|
if (results.length > 0) {
|
|
searchResults.innerHTML = results
|
|
.map((result) => {
|
|
const { doc, matchingAnchors } = result;
|
|
const queryTerms = window.searchNamespace.engine.tokenize(
|
|
searchTerm,
|
|
);
|
|
const highlightedTitle = window.searchNamespace.engine
|
|
.highlightTerms(
|
|
doc.title,
|
|
queryTerms,
|
|
);
|
|
const resolvedPath = window.searchNamespace.engine.resolvePath(
|
|
doc.path,
|
|
);
|
|
|
|
let html = `
|
|
<div class="search-result-item search-result-page">
|
|
<a href="${resolvedPath}">${highlightedTitle}</a>
|
|
</div>
|
|
`;
|
|
|
|
if (matchingAnchors && matchingAnchors.length > 0) {
|
|
matchingAnchors.forEach((anchor) => {
|
|
// Skip anchors that duplicate the page title
|
|
const normalizedAnchor = window.searchNamespace.engine
|
|
.normalizeForComparison(
|
|
anchor.text,
|
|
);
|
|
const normalizedTitle = window.searchNamespace.engine
|
|
.normalizeForComparison(
|
|
doc.title,
|
|
);
|
|
if (normalizedAnchor === normalizedTitle) {
|
|
return;
|
|
}
|
|
|
|
const highlightedAnchor = window.searchNamespace.engine
|
|
.highlightTerms(
|
|
anchor.text,
|
|
queryTerms,
|
|
);
|
|
const anchorPath = `${resolvedPath}#${anchor.id}`;
|
|
html += `
|
|
<div class="search-result-item search-result-anchor">
|
|
<a href="${anchorPath}">${highlightedAnchor}</a>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
return html;
|
|
})
|
|
.join("");
|
|
searchResults.style.display = "block";
|
|
if (searchContainer) searchContainer.classList.add("has-results");
|
|
} else {
|
|
searchResults.innerHTML =
|
|
'<div class="search-result-item">No results found</div>';
|
|
searchResults.style.display = "block";
|
|
if (searchContainer) searchContainer.classList.add("has-results");
|
|
}
|
|
} catch (error) {
|
|
console.error("Search error:", error);
|
|
searchResults.innerHTML =
|
|
'<div class="search-result-item">Search unavailable</div>';
|
|
searchResults.style.display = "block";
|
|
if (searchContainer) searchContainer.classList.add("has-results");
|
|
}
|
|
}, 150),
|
|
);
|
|
|
|
// Hide results when clicking outside
|
|
document.addEventListener("click", function (event) {
|
|
if (
|
|
!searchInput.contains(event.target) &&
|
|
!searchResults.contains(event.target)
|
|
) {
|
|
searchResults.style.display = "none";
|
|
if (searchContainer) searchContainer.classList.remove("has-results");
|
|
}
|
|
});
|
|
|
|
// Focus search when pressing slash key
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key === "/" && document.activeElement !== searchInput) {
|
|
event.preventDefault();
|
|
searchInput.focus();
|
|
}
|
|
|
|
// Close search results on Escape key
|
|
if (
|
|
event.key === "Escape" &&
|
|
(document.activeElement === searchInput ||
|
|
searchResults.style.display === "block")
|
|
) {
|
|
searchResults.style.display = "none";
|
|
if (searchContainer) searchContainer.classList.remove("has-results");
|
|
searchInput.blur();
|
|
}
|
|
});
|
|
|
|
setupDocumentEventHandlers(searchInput, searchResults, searchContainer);
|
|
}
|
|
|
|
function setupDocumentEventHandlers(
|
|
searchInput,
|
|
searchResults,
|
|
searchContainer,
|
|
) {
|
|
document.addEventListener("click", function (event) {
|
|
const isMobileSearchActive = mobileSearchPopup &&
|
|
mobileSearchPopup.classList.contains("active");
|
|
const isDesktopResultsVisible = searchResults.style.display === "block";
|
|
|
|
if (
|
|
isMobileSearchActive &&
|
|
!mobileSearchPopup.contains(event.target) &&
|
|
!searchInput.contains(event.target)
|
|
) {
|
|
closeMobileSearch();
|
|
}
|
|
|
|
if (
|
|
isDesktopResultsVisible &&
|
|
!searchInput.contains(event.target) &&
|
|
!searchResults.contains(event.target)
|
|
) {
|
|
searchResults.style.display = "none";
|
|
if (searchContainer) searchContainer.classList.remove("has-results");
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key === "/" && document.activeElement !== searchInput) {
|
|
event.preventDefault();
|
|
searchInput.focus();
|
|
}
|
|
|
|
if (
|
|
event.key === "Escape" &&
|
|
(document.activeElement === searchInput ||
|
|
searchResults.style.display === "block")
|
|
) {
|
|
searchResults.style.display = "none";
|
|
if (searchContainer) searchContainer.classList.remove("has-results");
|
|
searchInput.blur();
|
|
}
|
|
|
|
if (
|
|
event.key === "Escape" &&
|
|
mobileSearchPopup &&
|
|
mobileSearchPopup.classList.contains("active")
|
|
) {
|
|
closeMobileSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mobile search functionality
|
|
// This detects mobile viewport and adds click behavior
|
|
function isMobile() {
|
|
return window.innerWidth <= 800;
|
|
}
|
|
|
|
if (searchInput) {
|
|
// Add mobile search behavior
|
|
searchInput.addEventListener("click", function (e) {
|
|
if (isMobile()) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openMobileSearch();
|
|
}
|
|
// On desktop, we let the normal click behavior work (focus the input)
|
|
});
|
|
|
|
// Prevent typing on mobile (input should only open popup)
|
|
searchInput.addEventListener("keydown", function (e) {
|
|
if (isMobile()) {
|
|
e.preventDefault();
|
|
openMobileSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mobile search popup functionality
|
|
const mobileSearchPopup = document.getElementById("mobile-search-popup");
|
|
const mobileSearchInput = document.getElementById("mobile-search-input");
|
|
const mobileSearchResults = document.getElementById("mobile-search-results");
|
|
const closeMobileSearchBtn = document.getElementById("close-mobile-search");
|
|
|
|
function openMobileSearch() {
|
|
if (mobileSearchPopup) {
|
|
mobileSearchPopup.classList.add("active");
|
|
// Focus the input after a small delay to ensure the popup is visible
|
|
setTimeout(() => {
|
|
if (mobileSearchInput) {
|
|
mobileSearchInput.focus();
|
|
}
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function closeMobileSearch() {
|
|
if (mobileSearchPopup) {
|
|
mobileSearchPopup.classList.remove("active");
|
|
if (mobileSearchInput) {
|
|
mobileSearchInput.value = "";
|
|
}
|
|
if (mobileSearchResults) {
|
|
mobileSearchResults.innerHTML = "";
|
|
mobileSearchResults.style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (closeMobileSearchBtn) {
|
|
closeMobileSearchBtn.addEventListener("click", closeMobileSearch);
|
|
}
|
|
|
|
// Mobile search input
|
|
if (mobileSearchInput && mobileSearchResults) {
|
|
function handleMobileSearchInput() {
|
|
clearTimeout(mobileSearchTimeout);
|
|
const searchTerm = mobileSearchInput.value.trim();
|
|
if (searchTerm.length < 2) {
|
|
mobileSearchResults.innerHTML = "";
|
|
mobileSearchResults.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
mobileSearchTimeout = setTimeout(async () => {
|
|
// Verify the input still matches before proceeding
|
|
if (mobileSearchInput.value.trim() !== searchTerm) return;
|
|
|
|
// Show loading state
|
|
mobileSearchResults.innerHTML =
|
|
'<div class="search-result-item">Loading...</div>';
|
|
mobileSearchResults.style.display = "block";
|
|
|
|
try {
|
|
const results = await window.searchNamespace.engine.search(
|
|
searchTerm,
|
|
8,
|
|
);
|
|
// Verify again after async operation
|
|
if (mobileSearchInput.value.trim() !== searchTerm) return;
|
|
|
|
if (results.length > 0) {
|
|
mobileSearchResults.innerHTML = results
|
|
.map((result) => {
|
|
const { doc, matchingAnchors } = result;
|
|
const queryTerms = window.searchNamespace.engine.tokenize(
|
|
searchTerm,
|
|
);
|
|
const highlightedTitle = window.searchNamespace.engine
|
|
.highlightTerms(
|
|
doc.title,
|
|
queryTerms,
|
|
);
|
|
const resolvedPath = window.searchNamespace.engine.resolvePath(
|
|
doc.path,
|
|
);
|
|
|
|
// Build page result
|
|
let html = `
|
|
<div class="search-result-item search-result-page">
|
|
<a href="${resolvedPath}">${highlightedTitle}</a>
|
|
</div>
|
|
`;
|
|
|
|
// Add anchor results if any
|
|
if (matchingAnchors && matchingAnchors.length > 0) {
|
|
matchingAnchors.forEach((anchor) => {
|
|
// Skip anchors that duplicate the page title
|
|
const normalizedAnchor = window.searchNamespace.engine
|
|
.normalizeForComparison(
|
|
anchor.text,
|
|
);
|
|
const normalizedTitle = window.searchNamespace.engine
|
|
.normalizeForComparison(
|
|
doc.title,
|
|
);
|
|
if (normalizedAnchor === normalizedTitle) {
|
|
return;
|
|
}
|
|
|
|
const highlightedAnchor = window.searchNamespace.engine
|
|
.highlightTerms(
|
|
anchor.text,
|
|
queryTerms,
|
|
);
|
|
const sectionPreview = window.searchNamespace.engine
|
|
.generateSectionPreview(
|
|
doc,
|
|
anchor,
|
|
searchTerm,
|
|
100,
|
|
);
|
|
const anchorPath = `${resolvedPath}#${anchor.id}`;
|
|
html += `
|
|
<div class="search-result-item search-result-anchor">
|
|
<a href="${anchorPath}">
|
|
<div class="search-result-anchor-text">${highlightedAnchor}</div>
|
|
<div class="search-result-preview">${sectionPreview}</div>
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
return html;
|
|
})
|
|
.join("");
|
|
mobileSearchResults.style.display = "block";
|
|
} else {
|
|
mobileSearchResults.innerHTML =
|
|
'<div class="search-result-item">No results found</div>';
|
|
mobileSearchResults.style.display = "block";
|
|
}
|
|
} catch (error) {
|
|
console.error("Mobile search error:", error);
|
|
// Verify once more
|
|
if (mobileSearchInput.value.trim() !== searchTerm) return;
|
|
mobileSearchResults.innerHTML =
|
|
'<div class="search-result-item">Search unavailable</div>';
|
|
mobileSearchResults.style.display = "block";
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
mobileSearchInput.addEventListener("input", handleMobileSearchInput);
|
|
}
|
|
|
|
// Handle window resize to update mobile behavior
|
|
window.addEventListener("resize", function () {
|
|
// Close mobile search if window is resized to desktop size
|
|
if (
|
|
!isMobile() &&
|
|
mobileSearchPopup &&
|
|
mobileSearchPopup.classList.contains("active")
|
|
) {
|
|
closeMobileSearch();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function performSearch(query) {
|
|
query = query.trim();
|
|
const resultsContainer = document.getElementById("search-page-results");
|
|
|
|
if (query.length < 2) {
|
|
resultsContainer.innerHTML =
|
|
"<p>Please enter at least 2 characters to search</p>";
|
|
return;
|
|
}
|
|
|
|
// Cancel any pending search
|
|
if (searchPageController) {
|
|
searchPageController.abort();
|
|
}
|
|
searchPageController = new AbortController();
|
|
|
|
// Show loading state
|
|
resultsContainer.innerHTML = "<p>Searching...</p>";
|
|
|
|
try {
|
|
const results = await window.searchNamespace.engine.search(query, 50, {
|
|
signal: searchPageController.signal,
|
|
});
|
|
|
|
// Check if aborted before rendering
|
|
if (searchPageController.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
// Display results
|
|
if (results.length > 0) {
|
|
let html = '<ul class="search-results-list">';
|
|
const queryTerms = window.searchNamespace.engine.tokenize(query);
|
|
|
|
for (const result of results) {
|
|
const { doc, matchingAnchors } = result;
|
|
const highlightedTitle = window.searchNamespace.engine.highlightTerms(
|
|
doc.title,
|
|
queryTerms,
|
|
);
|
|
const preview = window.searchNamespace.engine.generatePreview(
|
|
doc.content,
|
|
query,
|
|
);
|
|
const resolvedPath = window.searchNamespace.engine.resolvePath(
|
|
doc.path,
|
|
);
|
|
|
|
// Page result
|
|
html += `<li class="search-result-item search-result-page">
|
|
<a href="${resolvedPath}">
|
|
<div class="search-result-title">${highlightedTitle}</div>
|
|
<div class="search-result-preview">${preview}</div>
|
|
</a>
|
|
</li>`;
|
|
|
|
// Anchor results
|
|
if (matchingAnchors && matchingAnchors.length > 0) {
|
|
matchingAnchors.forEach((anchor) => {
|
|
// Skip anchors that have the same text as the page title to avoid duplication
|
|
const normalizedAnchor = window.searchNamespace.engine
|
|
.normalizeForComparison(anchor.text);
|
|
const normalizedTitle = window.searchNamespace.engine
|
|
.normalizeForComparison(doc.title);
|
|
if (normalizedAnchor === normalizedTitle) {
|
|
return;
|
|
}
|
|
|
|
const highlightedAnchor = window.searchNamespace.engine
|
|
.highlightTerms(
|
|
anchor.text,
|
|
queryTerms,
|
|
);
|
|
const sectionPreview = window.searchNamespace.engine
|
|
.generateSectionPreview(
|
|
doc,
|
|
anchor,
|
|
query,
|
|
);
|
|
const anchorPath = `${resolvedPath}#${anchor.id}`;
|
|
html += `<li class="search-result-item search-result-anchor">
|
|
<a href="${anchorPath}">
|
|
<div class="search-result-anchor-text">${highlightedAnchor}</div>
|
|
<div class="search-result-preview">${sectionPreview}</div>
|
|
</a>
|
|
</li>`;
|
|
});
|
|
}
|
|
}
|
|
html += "</ul>";
|
|
resultsContainer.innerHTML = html;
|
|
} else {
|
|
resultsContainer.innerHTML = "<p>No results found</p>";
|
|
}
|
|
|
|
// Update URL with query
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("q", query);
|
|
window.history.replaceState({}, "", url.toString());
|
|
} catch (error) {
|
|
if (error.name === "AbortError") {
|
|
return;
|
|
}
|
|
console.error("Search error:", error);
|
|
resultsContainer.innerHTML = "<p>Search temporarily unavailable</p>";
|
|
}
|
|
}
|