import { query } from "./_generated/server"; import { v } from "convex/values"; // Search result type for both posts and pages const searchResultValidator = v.object({ _id: v.string(), type: v.union(v.literal("post"), v.literal("page")), slug: v.string(), title: v.string(), description: v.optional(v.string()), snippet: v.string(), anchor: v.optional(v.string()), // Anchor ID for scrolling to exact match location }); // Search across posts and pages export const search = query({ args: { query: v.string(), }, returns: v.array(searchResultValidator), handler: async (ctx, args) => { // Return empty results for empty queries if (!args.query.trim()) { return []; } const results: Array<{ _id: string; type: "post" | "page"; slug: string; title: string; description?: string; snippet: string; anchor?: string; }> = []; // Search posts by title const postsByTitle = await ctx.db .query("posts") .withSearchIndex("search_title", (q) => q.search("title", args.query).eq("published", true) ) .take(10); // Search posts by content const postsByContent = await ctx.db .query("posts") .withSearchIndex("search_content", (q) => q.search("content", args.query).eq("published", true) ) .take(10); // Search pages by title const pagesByTitle = await ctx.db .query("pages") .withSearchIndex("search_title", (q) => q.search("title", args.query).eq("published", true) ) .take(10); // Search pages by content const pagesByContent = await ctx.db .query("pages") .withSearchIndex("search_content", (q) => q.search("content", args.query).eq("published", true) ) .take(10); // Deduplicate and process post results const seenPostIds = new Set(); for (const post of [...postsByTitle, ...postsByContent]) { if (seenPostIds.has(post._id)) continue; seenPostIds.add(post._id); // Skip unlisted posts if (post.unlisted) continue; // Create snippet from content and find anchor const { snippet, anchor } = createSnippet(post.content, args.query, 120); results.push({ _id: post._id, type: "post" as const, slug: post.slug, title: post.title, description: post.description, snippet, anchor: anchor || undefined, }); } // Deduplicate and process page results const seenPageIds = new Set(); for (const page of [...pagesByTitle, ...pagesByContent]) { if (seenPageIds.has(page._id)) continue; seenPageIds.add(page._id); // Create snippet from content and find anchor const { snippet, anchor } = createSnippet(page.content, args.query, 120); results.push({ _id: page._id, type: "page" as const, slug: page.slug, title: page.title, snippet, anchor: anchor || undefined, }); } // Sort results: title matches first, then by relevance const queryLower = args.query.toLowerCase(); results.sort((a, b) => { const aInTitle = a.title.toLowerCase().includes(queryLower); const bInTitle = b.title.toLowerCase().includes(queryLower); if (aInTitle && !bInTitle) return -1; if (!aInTitle && bInTitle) return 1; return 0; }); // Limit to top 15 results return results.slice(0, 15); }, }); // Generate slug from heading text (same as frontend) function generateSlug(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim(); } // Find the nearest heading before a match position in the original content function findNearestHeading(content: string, matchPosition: number): string | null { const lines = content.split("\n"); const headings: Array<{ text: string; position: number; id: string }> = []; let currentPosition = 0; // Find all headings with their positions for (let i = 0; i < lines.length; i++) { const line = lines[i]; const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { const text = headingMatch[2].trim(); const id = generateSlug(text); headings.push({ text, position: currentPosition, id }); } // Add line length + newline to position currentPosition += line.length + 1; } // Find the last heading before the match position let nearestHeading: typeof headings[0] | null = null; for (const heading of headings) { if (heading.position <= matchPosition) { nearestHeading = heading; } else { break; } } return nearestHeading?.id || null; } // Helper to create a snippet around the search term and find anchor function createSnippet( content: string, searchTerm: string, maxLength: number ): { snippet: string; anchor: string | null } { const lowerSearchTerm = searchTerm.toLowerCase(); // Find the first occurrence in the original content for anchor lookup // This finds the match position before we clean the content const originalIndex = content.toLowerCase().indexOf(lowerSearchTerm); const anchor = originalIndex !== -1 ? findNearestHeading(content, originalIndex) : null; // Remove markdown syntax for cleaner snippets const cleanContent = content .replace(/#{1,6}\s/g, "") // Headers .replace(/\*\*([^*]+)\*\*/g, "$1") // Bold .replace(/\*([^*]+)\*/g, "$1") // Italic .replace(/`([^`]+)`/g, "$1") // Inline code .replace(/```[\s\S]*?```/g, "") // Code blocks .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Links .replace(/!\[([^\]]*)\]\([^)]+\)/g, "") // Images .replace(/\n+/g, " ") // Newlines to spaces .replace(/\s+/g, " ") // Multiple spaces to single .trim(); const lowerContent = cleanContent.toLowerCase(); const index = lowerContent.indexOf(lowerSearchTerm); if (index === -1) { // Term not found, return beginning of content return { snippet: cleanContent.slice(0, maxLength) + (cleanContent.length > maxLength ? "..." : ""), anchor: null, }; } // Calculate start position to center the search term const start = Math.max(0, index - Math.floor(maxLength / 3)); const end = Math.min(cleanContent.length, start + maxLength); let snippet = cleanContent.slice(start, end); // Add ellipsis if needed if (start > 0) snippet = "..." + snippet; if (end < cleanContent.length) snippet = snippet + "..."; return { snippet, anchor }; }