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 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 === "<" ? "<" : ">")) // 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}