spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/lib/search.mjs8 * Purpose : Tiny inverted index over names, topics, descriptions, READMEs9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { allOverviews } from './overview.mjs';1415/** Strip markdown/code noise from a README for indexing + snippets. */16export function stripMarkdown(source) {17 return String(source)18 .replace(/```[\s\S]*?```/g, ' ')19 .replace(/`[^`]*`/g, ' ')20 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')21 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')22 .replace(/<[^>]+>/g, ' ')23 .replace(/[#>*_~|-]{1,}/g, ' ')24 .replace(/\s+/g, ' ')25 .trim();26}2728/**29 * Build the search index (cached globally, refreshed on push).30 * @param {{config, repos, meta, cache}} ctx31 * @returns {Promise<{entries: object[]}>}32 */33export async function buildSearchIndex(ctx) {34 const cachePath = ctx.cache.path('global', 'search-index.json');35 const cached = ctx.cache.getJSON(cachePath);36 if (cached) return cached;37 const overviews = await allOverviews(ctx);38 const entries = [];39 for (const o of overviews) {40 let readmeText = '';41 if (o.head) {42 const readme = await ctx.repos.readme(o.name, o.head);43 if (readme) readmeText = stripMarkdown(readme.content.toString('utf8')).slice(0, 20000);44 }45 entries.push({46 name: o.name,47 description: o.description,48 topics: o.topics,49 topLanguage: o.topLanguage,50 lastPush: o.lastPush,51 readmeText,52 });53 }54 const index = { entries, builtAt: new Date().toISOString() };55 ctx.cache.setJSON(cachePath, index);56 return index;57}5859/**60 * Query the index. Returns grouped, scored results.61 * @param {{config, repos, meta, cache}} ctx62 * @param {string} query63 * @returns {Promise<{repos: object[], readmes: object[]}>}64 */65export async function search(ctx, query) {66 const terms = String(query ?? '')67 .toLowerCase()68 .split(/\s+/)69 .filter((t) => t.length >= 2)70 .slice(0, 8);71 if (terms.length === 0) return { repos: [], readmes: [] };72 const { entries } = await buildSearchIndex(ctx);73 const repoHits = [];74 const readmeHits = [];75 for (const entry of entries) {76 const name = entry.name.toLowerCase();77 const description = (entry.description ?? '').toLowerCase();78 const topics = (entry.topics ?? []).map((t) => t.toLowerCase());79 const readme = (entry.readmeText ?? '').toLowerCase();80 let score = 0;81 let readmeMatch = false;82 for (const term of terms) {83 if (name === term) score += 100;84 else if (name.includes(term)) score += 40;85 if (description.includes(term)) score += 15;86 if (topics.some((t) => t.includes(term))) score += 25;87 if (readme.includes(term)) {88 score += 5;89 readmeMatch = true;90 }91 }92 if (score === 0) continue;93 const hit = { ...entry, score };94 if (name.includes(terms[0]) || description.includes(terms[0]) || topics.some((t) => t.includes(terms[0]))) {95 repoHits.push(hit);96 }97 if (readmeMatch) {98 const idx = readme.indexOf(terms[0]);99 const start = Math.max(0, idx - 80);100 const snippet = entry.readmeText.slice(start, start + 220).trim();101 readmeHits.push({ ...hit, snippet: (start > 0 ? '…' : '') + snippet + '…' });102 }103 }104 repoHits.sort((a, b) => b.score - a.score);105 readmeHits.sort((a, b) => b.score - a.score);106 return { repos: repoHits.slice(0, 20), readmes: readmeHits.slice(0, 20) };107}108