import type { DocsEntry, DocsHeading } from './docs-content' import { extractHeadings } from './docs-content' export interface DocsSearchRecord { entry: DocsEntry markdown: string headings: DocsHeading[] plainText: string } export interface DocsSearchResult { entry: DocsEntry score: number excerpt: string } const DEFAULT_EXCERPT_LENGTH = 160 const EXCERPT_CONTEXT_BEFORE = 56 const EXCERPT_CONTEXT_AFTER = 104 const MAX_SEARCH_RESULTS = 12 const SEARCH_SCORE = { title: 80, slug: 36, group: 24, headings: 32, body: 8, } function stripMarkdown(markdown: string): string { return markdown .replace(/```[\s\S]*?```/g, ' ') .replace(/`([^`]+)`/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[#>*_\-|]/g, ' ') .replace(/\s+/g, ' ') .trim() } function createExcerpt(text: string, query: string): string { const lowerText = text.toLowerCase() const lowerQuery = query.toLowerCase() const matchIndex = lowerText.indexOf(lowerQuery) if (matchIndex < 0) { return text.slice(0, DEFAULT_EXCERPT_LENGTH) } const start = Math.max(0, matchIndex - EXCERPT_CONTEXT_BEFORE) const end = Math.min(text.length, matchIndex + query.length + EXCERPT_CONTEXT_AFTER) const prefix = start > 0 ? '...' : '' const suffix = end < text.length ? '...' : '' return `${prefix}${text.slice(start, end)}${suffix}` } export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise { const records = await Promise.all( entries.map(async (entry) => { const markdown = await entry.loader() return { entry, markdown, headings: extractHeadings(markdown), plainText: stripMarkdown(markdown), } }), ) return records } export function searchDocs(records: DocsSearchRecord[], rawQuery: string): DocsSearchResult[] { const query = rawQuery.trim().toLowerCase() if (!query) { return [] } return records .map((record) => { const title = record.entry.title.toLowerCase() const slug = record.entry.slug.toLowerCase() const group = record.entry.group.toLowerCase() const headings = record.headings.map((heading) => heading.text).join(' ').toLowerCase() const body = record.plainText.toLowerCase() let score = 0 if (title.includes(query)) score += SEARCH_SCORE.title if (slug.includes(query)) score += SEARCH_SCORE.slug if (group.includes(query)) score += SEARCH_SCORE.group if (headings.includes(query)) score += SEARCH_SCORE.headings if (body.includes(query)) score += SEARCH_SCORE.body return { entry: record.entry, score, excerpt: createExcerpt(record.plainText, rawQuery.trim()), } }) .filter((result) => result.score > 0) .sort((a, b) => b.score - a.score || a.entry.order - b.entry.order) .slice(0, MAX_SEARCH_RESULTS) }