Openstatus
www.openstatus.dev
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 === "<" ? "<" : ">")) // 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}