/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/lib/search.mjs * Purpose : Tiny inverted index over names, topics, descriptions, READMEs * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { allOverviews } from './overview.mjs'; /** Strip markdown/code noise from a README for indexing + snippets. */ export function stripMarkdown(source) { return String(source) .replace(/```[\s\S]*?```/g, ' ') .replace(/`[^`]*`/g, ' ') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/<[^>]+>/g, ' ') .replace(/[#>*_~|-]{1,}/g, ' ') .replace(/\s+/g, ' ') .trim(); } /** * Build the search index (cached globally, refreshed on push). * @param {{config, repos, meta, cache}} ctx * @returns {Promise<{entries: object[]}>} */ export async function buildSearchIndex(ctx) { const cachePath = ctx.cache.path('global', 'search-index.json'); const cached = ctx.cache.getJSON(cachePath); if (cached) return cached; const overviews = await allOverviews(ctx); const entries = []; for (const o of overviews) { let readmeText = ''; if (o.head) { const readme = await ctx.repos.readme(o.name, o.head); if (readme) readmeText = stripMarkdown(readme.content.toString('utf8')).slice(0, 20000); } entries.push({ name: o.name, description: o.description, topics: o.topics, topLanguage: o.topLanguage, lastPush: o.lastPush, readmeText, }); } const index = { entries, builtAt: new Date().toISOString() }; ctx.cache.setJSON(cachePath, index); return index; } /** * Query the index. Returns grouped, scored results. * @param {{config, repos, meta, cache}} ctx * @param {string} query * @returns {Promise<{repos: object[], readmes: object[]}>} */ export async function search(ctx, query) { const terms = String(query ?? '') .toLowerCase() .split(/\s+/) .filter((t) => t.length >= 2) .slice(0, 8); if (terms.length === 0) return { repos: [], readmes: [] }; const { entries } = await buildSearchIndex(ctx); const repoHits = []; const readmeHits = []; for (const entry of entries) { const name = entry.name.toLowerCase(); const description = (entry.description ?? '').toLowerCase(); const topics = (entry.topics ?? []).map((t) => t.toLowerCase()); const readme = (entry.readmeText ?? '').toLowerCase(); let score = 0; let readmeMatch = false; for (const term of terms) { if (name === term) score += 100; else if (name.includes(term)) score += 40; if (description.includes(term)) score += 15; if (topics.some((t) => t.includes(term))) score += 25; if (readme.includes(term)) { score += 5; readmeMatch = true; } } if (score === 0) continue; const hit = { ...entry, score }; if (name.includes(terms[0]) || description.includes(terms[0]) || topics.some((t) => t.includes(terms[0]))) { repoHits.push(hit); } if (readmeMatch) { const idx = readme.indexOf(terms[0]); const start = Math.max(0, idx - 80); const snippet = entry.readmeText.slice(start, start + 220).trim(); readmeHits.push({ ...hit, snippet: (start > 0 ? '…' : '') + snippet + '…' }); } } repoHits.sort((a, b) => b.score - a.score); readmeHits.sort((a, b) => b.score - a.score); return { repos: repoHits.slice(0, 20), readmes: readmeHits.slice(0, 20) }; }