Openstatus
www.openstatus.dev
1import { existsSync } from "node:fs";
2import { join } from "node:path";
3import { getImageDimensions } from "@/lib/image-dimensions";
4import { cn } from "@/lib/utils";
5import { Button } from "@openstatus/ui";
6import { MDXRemote, type MDXRemoteProps } from "next-mdx-remote/rsc";
7import Image from "next/image";
8import Link from "next/link";
9import React from "react";
10import { Tweet, type TweetProps } from "react-tweet";
11import { highlight } from "sugar-high";
12import { CopyButton } from "./copy-button";
13import { HighlightText } from "./highlight-text";
14import { ImageZoom } from "./image-zoom";
15import { LatencyChartTable } from "./latency-chart-table";
16
17function Table({
18 data,
19}: {
20 data: { headers: React.ReactNode[]; rows: React.ReactNode[][] };
21}) {
22 const headers = data.headers.map((header: React.ReactNode, index: number) => (
23 <th key={index}>{header}</th>
24 ));
25 const rows = data.rows.map((row: React.ReactNode[], index: number) => (
26 <tr key={index}>
27 {row.map((cell: React.ReactNode, cellIndex: number) => (
28 <td key={cellIndex}>{cell}</td>
29 ))}
30 </tr>
31 ));
32
33 return (
34 <div className="table-wrapper">
35 <table>
36 <thead>
37 <tr>{headers}</tr>
38 </thead>
39 <tbody>{rows}</tbody>
40 </table>
41 </div>
42 );
43}
44
45function Grid({
46 cols = 2,
47 children,
48 className,
49}: {
50 cols?: 1 | 2 | 3 | 4 | 5;
51 children: React.ReactNode;
52 className?: string;
53}) {
54 const colsClass = {
55 1: "md:grid-cols-1",
56 2: "md:grid-cols-2",
57 3: "md:grid-cols-3",
58 4: "md:grid-cols-4",
59 5: "md:grid-cols-5",
60 };
61
62 // Remove top border from all except first row
63 const topBorderClass = {
64 1: "[&>*]:border-t-0 [&>*:first-child]:border-t",
65 2: "[&>*]:border-t-0 [&>*:first-child]:border-t md:[&>*:nth-child(-n+2)]:border-t",
66 3: "[&>*]:border-t-0 [&>*:first-child]:border-t md:[&>*:nth-child(-n+3)]:border-t",
67 4: "[&>*]:border-t-0 [&>*:first-child]:border-t md:[&>*:nth-child(-n+4)]:border-t",
68 5: "[&>*]:border-t-0 [&>*:first-child]:border-t md:[&>*:nth-child(-n+5)]:border-t",
69 };
70
71 // Remove left border from all except first column (only on md+ screens)
72 const leftBorderClass = {
73 1: "",
74 2: "md:[&>*]:border-l-0 md:[&>*:nth-child(2n+1)]:border-l",
75 3: "md:[&>*]:border-l-0 md:[&>*:nth-child(3n+1)]:border-l",
76 4: "md:[&>*]:border-l-0 md:[&>*:nth-child(4n+1)]:border-l",
77 5: "md:[&>*]:border-l-0 md:[&>*:nth-child(5n+1)]:border-l",
78 };
79
80 return (
81 <div
82 className={cn(
83 "my-4 grid grid-cols-1",
84 "[&>*]:border [&>*]:border-border [&>*]:p-4",
85 // NOTE: remove extra margin from prose grid cells of first and last element
86 "[&>*>*:first-child]:!mt-0 [&>*>*:last-child]:!mb-0",
87 colsClass[cols],
88 topBorderClass[cols],
89 leftBorderClass[cols],
90 className,
91 )}
92 >
93 {children}
94 </div>
95 );
96}
97
98function CustomLink(props: React.ComponentProps<"a">) {
99 const href = props.href ?? "";
100
101 if (href.startsWith("/")) {
102 return (
103 <Link href={href} {...props}>
104 {props.children}
105 </Link>
106 );
107 }
108
109 if (href.startsWith("#")) {
110 return <a {...props} />;
111 }
112
113 return <a target="_blank" rel="noopener noreferrer" {...props} />;
114}
115
116function ButtonLink(
117 props: React.ComponentProps<typeof Button> & { href: string },
118) {
119 return (
120 <Button
121 variant="outline"
122 size="lg"
123 className="no-underline! h-auto rounded-none px-4 py-4 text-base"
124 asChild
125 {...props}
126 >
127 <CustomLink href={props.href}>{props.children}</CustomLink>
128 </Button>
129 );
130}
131
132function Code({ children, className, ...props }: React.ComponentProps<"code">) {
133 // Only apply syntax highlighting if a language is specified (className contains "language-")
134 const hasLanguage = className?.includes("language-");
135
136 if (hasLanguage) {
137 const codeHTML = highlight(children?.toString() ?? "");
138 return (
139 <code
140 // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
141 dangerouslySetInnerHTML={{ __html: codeHTML }}
142 className={className}
143 {...props}
144 />
145 );
146 }
147
148 // Plain code block without language - render as-is
149 return (
150 <code className={className} {...props}>
151 {children}
152 </code>
153 );
154}
155
156function extractTextFromReactNode(node: React.ReactNode): string {
157 if (typeof node === "string") {
158 return node;
159 }
160 if (typeof node === "number") {
161 return String(node);
162 }
163 if (Array.isArray(node)) {
164 return node.map(extractTextFromReactNode).join("");
165 }
166 if (React.isValidElement(node)) {
167 const props = node.props as { children?: React.ReactNode };
168 if (props.children) {
169 return extractTextFromReactNode(props.children);
170 }
171 }
172 return "";
173}
174
175function Pre({ children, ...props }: React.ComponentProps<"pre">) {
176 const textContent = extractTextFromReactNode(children);
177 return (
178 <div className="relative">
179 <pre {...props}>{children}</pre>
180 <CopyButton
181 copyText={textContent}
182 className="absolute top-px right-px backdrop-blur-xs"
183 />
184 </div>
185 );
186}
187
188export function slugify(str: string) {
189 return str
190 .toString()
191 .toLowerCase()
192 .trim() // Remove whitespace from both ends of a string
193 .replace(/\s+/g, "-") // Replace spaces with -
194 .replace(/&/g, "-and-") // Replace & with 'and'
195 .replace(/[^\w-]+/g, "") // Remove all non-word characters except for -
196 .replace(/--+/g, "-"); // Replace multiple - with single -
197}
198
199function createHeading(level: number) {
200 const Heading = ({ children }: { children: React.ReactNode }) => {
201 const slug = slugify(children?.toString() ?? "");
202 return React.createElement(
203 `h${level}`,
204 { id: slug },
205 [
206 React.createElement("a", {
207 href: `#${slug}`,
208 key: `link-${slug}`,
209 className: "anchor",
210 }),
211 ],
212 children,
213 );
214 };
215
216 Heading.displayName = `Heading${level}`;
217
218 return Heading;
219}
220
221function Details({
222 children,
223 summary,
224 open = false,
225}: {
226 children: React.ReactNode;
227 summary: string;
228 open?: boolean;
229}) {
230 return (
231 <details open={open}>
232 <summary>{summary}</summary>
233 {React.isValidElement(children)
234 ? // biome-ignore lint/suspicious/noExplicitAny: <explanation>
235 React.cloneElement(children, { hidden: "until-found" } as any)
236 : children}
237 </details>
238 );
239}
240
241function CustomImage({
242 className,
243 ...props
244}: React.ComponentProps<typeof Image>) {
245 const { src, alt, width, height, ...rest } = props;
246
247 if (!src || typeof src !== "string") {
248 return (
249 <figure>
250 <ImageZoom
251 backdropClassName={cn(
252 '[&_[data-rmiz-modal-overlay="visible"]]:bg-background/80',
253 )}
254 zoomMargin={16}
255 >
256 <Image
257 className={className}
258 src={src}
259 alt={alt ?? "image"}
260 fill
261 sizes="100vw"
262 style={{ objectFit: "contain" }}
263 {...rest}
264 />
265 </ImageZoom>
266 <figcaption>{alt}</figcaption>
267 </figure>
268 );
269 }
270
271 // Get actual image dimensions from filesystem
272 const dimensions = getImageDimensions(src);
273 const imageWidth = width || dimensions?.width || 1200;
274 const imageHeight = height || dimensions?.height || 630;
275
276 // Generate dark mode image path by adding .dark before extension
277 const getDarkImagePath = (path: string) => {
278 const match = path.match(/^(.+)(\.[^.]+)$/);
279 if (match) {
280 return `${match[1]}.dark${match[2]}`;
281 }
282 return path;
283 };
284
285 // Check if dark image exists, fallback to light version if not
286 const checkDarkImageExists = (darkPath: string) => {
287 // If path starts with /, it's in the public directory
288 if (darkPath.startsWith("/")) {
289 const publicPath = join(process.cwd(), "public", darkPath);
290 return existsSync(publicPath);
291 }
292 // For relative paths, check relative to public
293 const publicPath = join(process.cwd(), "public", darkPath);
294 return existsSync(publicPath);
295 };
296
297 const darkSrc = getDarkImagePath(src);
298 const useDarkImage = checkDarkImageExists(darkSrc);
299
300 return (
301 <figure>
302 <ImageZoom
303 backdropClassName={cn(
304 '[&_[data-rmiz-modal-overlay="visible"]]:bg-black/80',
305 )}
306 zoomMargin={16}
307 >
308 <Image
309 {...rest}
310 src={src}
311 alt={alt ?? ""}
312 width={imageWidth}
313 height={imageHeight}
314 sizes="100vw"
315 style={{ width: "100%", height: "auto" }}
316 className={cn("block dark:hidden", className)}
317 />
318 </ImageZoom>
319 <ImageZoom
320 backdropClassName={cn(
321 '[&_[data-rmiz-modal-overlay="visible"]]:bg-black/80',
322 )}
323 zoomMargin={16}
324 >
325 <Image
326 {...rest}
327 src={useDarkImage ? darkSrc : src}
328 alt={alt ?? ""}
329 width={imageWidth}
330 height={imageHeight}
331 sizes="100vw"
332 style={{ width: "100%", height: "auto" }}
333 className={cn("hidden dark:block", className)}
334 />
335 </ImageZoom>
336 {alt && <figcaption>{alt}</figcaption>}
337 </figure>
338 );
339}
340
341export const components = {
342 h1: createHeading(1),
343 h2: createHeading(2),
344 h3: createHeading(3),
345 h4: createHeading(4),
346 h5: createHeading(5),
347 h6: createHeading(6),
348 Image: CustomImage,
349 a: CustomLink,
350 ButtonLink: ButtonLink,
351 code: Code,
352 pre: Pre,
353 Table,
354 Grid,
355 Details, // Capital D for JSX usage with props
356 details: Details, // lowercase for HTML tag replacement
357 SimpleChart: LatencyChartTable,
358 Tweet: (props: TweetProps) => {
359 return (
360 <div data-theme="light" className="not-prose [&>div]:mx-auto">
361 <Tweet {...props} />
362 </div>
363 );
364 },
365};
366
367function MDXContent(props: MDXRemoteProps) {
368 return (
369 <MDXRemote
370 {...props}
371 components={
372 {
373 ...components,
374 ...props.components,
375 } as MDXRemoteProps["components"]
376 }
377 />
378 );
379}
380
381export function CustomMDX(props: MDXRemoteProps) {
382 return (
383 <React.Suspense fallback={<MDXContent {...props} />}>
384 <HighlightText>
385 <MDXContent {...props} />
386 </HighlightText>
387 </React.Suspense>
388 );
389}