/** * Search Functionality using Fuse.js * Provides full-text search across help content */ import Fuse, { type IFuseOptions } from 'fuse.js'; import type { HelpContent, FAQItem, FeatureItem, GettingStartedItem, ChangelogItem, } from '@manacore/shared-help-types'; import type { SearchableItem, SearchResult, SearchOptions, SearchIndexConfig, } from '@manacore/shared-help-types'; import { generateExcerpt, stripHtml } from './parser.js'; const DEFAULT_CONFIG: SearchIndexConfig = { titleWeight: 2, contentWeight: 1, tagsWeight: 1.5, threshold: 0.3, minMatchCharLength: 2, }; /** * Convert HelpContent to searchable items */ export function flattenContentForSearch(content: HelpContent): SearchableItem[] { const items: SearchableItem[] = []; // FAQs for (const faq of content.faq) { items.push({ id: faq.id, type: 'faq', title: faq.question, question: faq.question, content: stripHtml(faq.answer), tags: faq.tags, }); } // Features for (const feature of content.features) { items.push({ id: feature.id, type: 'feature', title: feature.title, description: feature.description, content: stripHtml(feature.content), tags: feature.highlights, }); } // Getting Started Guides for (const guide of content.gettingStarted) { items.push({ id: guide.id, type: 'guide', title: guide.title, description: guide.description, content: stripHtml(guide.content), }); } // Changelog for (const log of content.changelog) { items.push({ id: log.id, type: 'changelog', title: `${log.version} - ${log.title}`, content: stripHtml(log.content), description: log.summary, }); } return items; } /** * Build a Fuse.js search index from help content */ export function buildSearchIndex( content: HelpContent, config: SearchIndexConfig = DEFAULT_CONFIG ): Fuse { const items = flattenContentForSearch(content); const fuseOptions: IFuseOptions = { keys: [ { name: 'title', weight: config.titleWeight ?? 2 }, { name: 'question', weight: config.titleWeight ?? 2 }, { name: 'content', weight: config.contentWeight ?? 1 }, { name: 'description', weight: config.contentWeight ?? 1 }, { name: 'tags', weight: config.tagsWeight ?? 1.5 }, ], threshold: config.threshold ?? 0.3, includeScore: true, minMatchCharLength: config.minMatchCharLength ?? 2, ignoreLocation: true, }; return new Fuse(items, fuseOptions); } /** * Find the original item from content */ function findOriginalItem( id: string, type: string, content: HelpContent ): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null { switch (type) { case 'faq': return content.faq.find((item) => item.id === id) ?? null; case 'feature': return content.features.find((item) => item.id === id) ?? null; case 'guide': return content.gettingStarted.find((item) => item.id === id) ?? null; case 'changelog': return content.changelog.find((item) => item.id === id) ?? null; default: return null; } } /** * Highlight matching text in content */ function highlightMatch(text: string, query: string): string { if (!query.trim()) return text; const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); return text.replace(regex, '$1'); } /** * Search help content */ export function search( index: Fuse, query: string, content: HelpContent, options: SearchOptions = {} ): SearchResult[] { const { limit = 10, threshold, types, appId } = options; if (!query.trim()) { return []; } let results = index.search(query, { limit: limit * 2 }); // Filter by type if specified if (types && types.length > 0) { results = results.filter((r) => types.includes(r.item.type)); } // Filter by app if specified if (appId) { results = results.filter((r) => { const originalItem = findOriginalItem(r.item.id, r.item.type, content); if (!originalItem) return true; if (!originalItem.appSpecific) return true; return originalItem.apps?.includes(appId); }); } // Apply threshold filter if specified if (threshold !== undefined) { results = results.filter((r) => (r.score ?? 1) <= threshold); } // Limit results results = results.slice(0, limit); const mappedResults: SearchResult[] = []; for (const result of results) { const originalItem = findOriginalItem(result.item.id, result.item.type, content); if (!originalItem) continue; mappedResults.push({ id: result.item.id, type: result.item.type, title: result.item.title, excerpt: generateExcerpt(result.item.content, 150), score: result.score ?? 1, highlight: highlightMatch(result.item.title, query), item: originalItem, }); } return mappedResults; } /** * Create a search function with pre-built index */ export function createSearcher(content: HelpContent, config?: SearchIndexConfig) { const index = buildSearchIndex(content, config); return (query: string, options?: SearchOptions) => search(index, query, content, options); }