Openstatus www.openstatus.dev
at main 240 lines 6.4 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 ...getPages("guides"), 74 home, 75 ]; 76 } else { 77 if (p) results = getPages(p); 78 } 79 80 const searchMap = new Map< 81 string, 82 { 83 title: boolean; 84 content: boolean; 85 } 86 >(); 87 88 results = results 89 .filter((result) => { 90 if (!q) return true; 91 92 const hasSearchTitle = result.metadata.title 93 .toLowerCase() 94 .includes(q.toLowerCase()); 95 const hasSearchContent = result.content 96 .toLowerCase() 97 .includes(q.toLowerCase()); 98 99 searchMap.set(result.slug, { 100 title: hasSearchTitle, 101 content: hasSearchContent, 102 }); 103 104 return hasSearchTitle || hasSearchContent; 105 }) 106 .map((result) => { 107 const search = searchMap.get(result.slug); 108 109 // Find the closest heading to the search match and add it as an anchor 110 let href = result.href; 111 112 // Add query parameter for highlighting 113 if (q) { 114 href = `${href}?q=${encodeURIComponent(q)}`; 115 } 116 117 if (q && search?.content) { 118 const headingSlug = findClosestHeading(result.content, q); 119 if (headingSlug) { 120 href = `${href}#${headingSlug}`; 121 } 122 } 123 124 const content = 125 search?.content || !search?.title 126 ? getContentSnippet(result.content, q) 127 : ""; 128 129 return { 130 ...result, 131 content, 132 href, 133 }; 134 }); 135 136 return results; 137} 138 139const WORKDS_BEFORE = 2; 140const WORKDS_AFTER = 20; 141 142function getContentSnippet( 143 mdxContent: string, 144 searchQuery: string | null | undefined, 145): string { 146 if (!searchQuery) { 147 return `${mdxContent.slice(0, 100)}...`; 148 } 149 150 const content = sanitizeContent(mdxContent.toLowerCase()); 151 const searchLower = searchQuery.toLowerCase(); 152 const matchIndex = content.indexOf(searchLower); 153 154 if (matchIndex === -1) { 155 // No match found, return first 100 chars 156 return `${content.slice(0, 100)}...`; 157 } 158 159 // Find start of snippet (go back N words) 160 let start = matchIndex; 161 for (let i = 0; i < WORKDS_BEFORE && start > 0; i++) { 162 const prevSpace = content.lastIndexOf(" ", start - 2); 163 if (prevSpace === -1) break; 164 start = prevSpace + 1; 165 } 166 167 // Find end of snippet (go forward N words) 168 let end = matchIndex + searchQuery.length; 169 for (let i = 0; i < WORKDS_AFTER && end < content.length; i++) { 170 const nextSpace = content.indexOf(" ", end + 1); 171 if (nextSpace === -1) { 172 end = content.length; 173 break; 174 } 175 end = nextSpace; 176 } 177 178 // Extract snippet 179 let snippet = content.slice(start, end).trim(); 180 181 if (!snippet) return snippet; 182 183 if (start > 0) snippet = `...${snippet}`; 184 if (end < content.length) snippet = `${snippet}...`; 185 186 return snippet; 187} 188 189export function sanitizeContent(input: string) { 190 return sanitizeHtml(input) 191 .replace(/<[^>]+>/g, "") // strip JSX tags 192 .replace(/^#{1,6}\s+/gm, "") // strip markdown heading symbols, keep text 193 .replace(/!\[.*?\]\(.*?\)/g, "") // strip images 194 .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // keep link text 195 .replace(/\*\*(.*?)\*\*/g, "$1") // strip bold 196 .replace(/__(.*?)__/g, "$1") // strip italic 197 .replace(/_(.*?)_/g, "$1") // strip underline 198 .replace(/[`*>~]/g, "") // strip most formatting 199 .replace(/\s+/g, " ") // collapse whitespace 200 .replace(/[<>]/g, (c) => (c === "<" ? "&lt;" : "&gt;")) // escape any remaining angle brackets 201 .trim(); 202} 203 204/** 205 * Find the closest heading before the search match and return its slug 206 */ 207function findClosestHeading( 208 mdxContent: string, 209 searchQuery: string | null | undefined, 210): string | null { 211 if (!searchQuery) return null; 212 213 const searchLower = searchQuery.toLowerCase(); 214 const contentLower = mdxContent.toLowerCase(); 215 const matchIndex = contentLower.indexOf(searchLower); 216 217 if (matchIndex === -1) return null; 218 219 // Look for headings before the match (## Heading, ### Heading, etc.) 220 const contentBeforeMatch = mdxContent.slice(0, matchIndex); 221 const headingRegex = /^#{1,6}\s+(.+)$/gm; 222 const headings: { text: string; index: number }[] = []; 223 224 let match = headingRegex.exec(contentBeforeMatch); 225 while (match !== null) { 226 headings.push({ 227 text: match[1].trim(), 228 index: match.index, 229 }); 230 match = headingRegex.exec(contentBeforeMatch); 231 } 232 233 // Return the closest heading (last one before the match) 234 if (headings.length > 0) { 235 const closestHeading = headings[headings.length - 1]; 236 return slugify(closestHeading.text); 237 } 238 239 return null; 240}