Openstatus www.openstatus.dev
at main 389 lines 10 kB view raw
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}