Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 239 lines 6.3 kB view raw
1import { slugify } from "@/content/mdx"; 2import { 3 type MDXData, 4 PAGE_TYPES, 5 getHomePage, 6 getPages, 7} from "@/content/utils"; 8import sanitizeHtml from "sanitize-html"; 9import { z } from "zod"; 10 11const SearchSchema = z.object({ 12 p: z.enum(PAGE_TYPES).nullish(), 13 q: z.string().nullish(), 14}); 15 16export type SearchParams = z.infer<typeof SearchSchema>; 17 18export async function GET(request: Request) { 19 const { searchParams } = new URL(request.url); 20 const query = searchParams.get("q"); 21 const page = searchParams.get("p"); 22 23 const params = SearchSchema.safeParse({ 24 p: page, 25 q: query, 26 }); 27 28 if (!params.success) { 29 console.error(params.error); 30 return new Response(JSON.stringify({ error: params.error.message }), { 31 status: 400, 32 }); 33 } 34 35 if (!params.data.p) { 36 return new Response(JSON.stringify([]), { 37 status: 200, 38 }); 39 } 40 41 const results = search(params.data).sort((a, b) => { 42 return b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime(); 43 }); 44 45 return new Response(JSON.stringify(results), { 46 status: 200, 47 }); 48} 49 50function search(params: SearchParams) { 51 const { p, q } = params; 52 let results: MDXData[] = []; 53 54 if (p === "tools") { 55 results = getPages("tools").filter((tool) => tool.slug !== "checker-slug"); 56 } else if (p === "product") { 57 const home = getHomePage(); 58 // NOTE: we override /home with / for the home.mdx file 59 home.href = "/"; 60 home.metadata.title = "Homepage"; 61 results = [home, ...getPages("product")]; 62 } else if (p === "all") { 63 const home = getHomePage(); 64 // NOTE: we override /home with / for the home.mdx file 65 home.href = "/"; 66 home.metadata.title = "Homepage"; 67 results = [ 68 ...getPages("blog"), 69 ...getPages("changelog"), 70 ...getPages("tools").filter((tool) => tool.slug !== "checker-slug"), 71 ...getPages("compare"), 72 ...getPages("product"), 73 home, 74 ]; 75 } else { 76 if (p) results = getPages(p); 77 } 78 79 const searchMap = new Map< 80 string, 81 { 82 title: boolean; 83 content: boolean; 84 } 85 >(); 86 87 results = results 88 .filter((result) => { 89 if (!q) return true; 90 91 const hasSearchTitle = result.metadata.title 92 .toLowerCase() 93 .includes(q.toLowerCase()); 94 const hasSearchContent = result.content 95 .toLowerCase() 96 .includes(q.toLowerCase()); 97 98 searchMap.set(result.slug, { 99 title: hasSearchTitle, 100 content: hasSearchContent, 101 }); 102 103 return hasSearchTitle || hasSearchContent; 104 }) 105 .map((result) => { 106 const search = searchMap.get(result.slug); 107 108 // Find the closest heading to the search match and add it as an anchor 109 let href = result.href; 110 111 // Add query parameter for highlighting 112 if (q) { 113 href = `${href}?q=${encodeURIComponent(q)}`; 114 } 115 116 if (q && search?.content) { 117 const headingSlug = findClosestHeading(result.content, q); 118 if (headingSlug) { 119 href = `${href}#${headingSlug}`; 120 } 121 } 122 123 const content = 124 search?.content || !search?.title 125 ? getContentSnippet(result.content, q) 126 : ""; 127 128 return { 129 ...result, 130 content, 131 href, 132 }; 133 }); 134 135 return results; 136} 137 138const WORKDS_BEFORE = 2; 139const WORKDS_AFTER = 20; 140 141function getContentSnippet( 142 mdxContent: string, 143 searchQuery: string | null | undefined, 144): string { 145 if (!searchQuery) { 146 return `${mdxContent.slice(0, 100)}...`; 147 } 148 149 const content = sanitizeContent(mdxContent.toLowerCase()); 150 const searchLower = searchQuery.toLowerCase(); 151 const matchIndex = content.indexOf(searchLower); 152 153 if (matchIndex === -1) { 154 // No match found, return first 100 chars 155 return `${content.slice(0, 100)}...`; 156 } 157 158 // Find start of snippet (go back N words) 159 let start = matchIndex; 160 for (let i = 0; i < WORKDS_BEFORE && start > 0; i++) { 161 const prevSpace = content.lastIndexOf(" ", start - 2); 162 if (prevSpace === -1) break; 163 start = prevSpace + 1; 164 } 165 166 // Find end of snippet (go forward N words) 167 let end = matchIndex + searchQuery.length; 168 for (let i = 0; i < WORKDS_AFTER && end < content.length; i++) { 169 const nextSpace = content.indexOf(" ", end + 1); 170 if (nextSpace === -1) { 171 end = content.length; 172 break; 173 } 174 end = nextSpace; 175 } 176 177 // Extract snippet 178 let snippet = content.slice(start, end).trim(); 179 180 if (!snippet) return snippet; 181 182 if (start > 0) snippet = `...${snippet}`; 183 if (end < content.length) snippet = `${snippet}...`; 184 185 return snippet; 186} 187 188export function sanitizeContent(input: string) { 189 return sanitizeHtml(input) 190 .replace(/<[^>]+>/g, "") // strip JSX tags 191 .replace(/^#{1,6}\s+/gm, "") // strip markdown heading symbols, keep text 192 .replace(/!\[.*?\]\(.*?\)/g, "") // strip images 193 .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // keep link text 194 .replace(/\*\*(.*?)\*\*/g, "$1") // strip bold 195 .replace(/__(.*?)__/g, "$1") // strip italic 196 .replace(/_(.*?)_/g, "$1") // strip underline 197 .replace(/[`*>~]/g, "") // strip most formatting 198 .replace(/\s+/g, " ") // collapse whitespace 199 .replace(/[<>]/g, (c) => (c === "<" ? "&lt;" : "&gt;")) // escape any remaining angle brackets 200 .trim(); 201} 202 203/** 204 * Find the closest heading before the search match and return its slug 205 */ 206function findClosestHeading( 207 mdxContent: string, 208 searchQuery: string | null | undefined, 209): string | null { 210 if (!searchQuery) return null; 211 212 const searchLower = searchQuery.toLowerCase(); 213 const contentLower = mdxContent.toLowerCase(); 214 const matchIndex = contentLower.indexOf(searchLower); 215 216 if (matchIndex === -1) return null; 217 218 // Look for headings before the match (## Heading, ### Heading, etc.) 219 const contentBeforeMatch = mdxContent.slice(0, matchIndex); 220 const headingRegex = /^#{1,6}\s+(.+)$/gm; 221 const headings: { text: string; index: number }[] = []; 222 223 let match = headingRegex.exec(contentBeforeMatch); 224 while (match !== null) { 225 headings.push({ 226 text: match[1].trim(), 227 index: match.index, 228 }); 229 match = headingRegex.exec(contentBeforeMatch); 230 } 231 232 // Return the closest heading (last one before the match) 233 if (headings.length > 0) { 234 const closestHeading = headings[headings.length - 1]; 235 return slugify(closestHeading.text); 236 } 237 238 return null; 239}