MDX Content

Renders MDX content with component mapping.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/mdx-content.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import { evaluate } from "@mdx-js/mdx"; import type React from "react"; import * as runtime from "react/jsx-runtime"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { CodeBlock } from "../code-block/code-block"; import { StaticCode } from "../static-code/static-code"; type MDXContentProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any components?: Record<string, React.ComponentType<any>>; content: string; enableMDX?: boolean; }; const MDXComponents: Components = { a: ({ children, href, ...props }: React.ComponentProps<"a">) => ( <a className="text-primary underline underline-offset-4 hover:text-primary/80 font-medium" href={href} {...props} > {children} </a> ), blockquote: ({ children, ...props }: React.ComponentProps<"blockquote">) => ( <blockquote className="border-l border-primary pl-4 italic text-muted-foreground my-6 py-2 text-sm" {...props} > {children} </blockquote> ), code: ({ children, className, ...props }: React.ComponentProps<"code">) => { if (typeof className === "string" && className.startsWith("language-")) { const language = className.replace(/^language-/, ""); const text = typeof children === "string" ? children.replace(/\n$/, "") : String(children ?? ""); return <StaticCode code={text} language={language} />; } return ( <code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props} > {children} </code> ); }, em: ({ children, ...props }: React.ComponentProps<"em">) => ( <em className="italic" {...props}> {children} </em> ), h1: ({ children, ...props }: React.ComponentProps<"h1">) => ( <h1 className="text-2xl font-semibold mt-8 mb-4" {...props}> {children} </h1> ), h2: ({ children, ...props }: React.ComponentProps<"h2">) => ( <h2 className="text-xl font-semibold mt-6 mb-3" {...props}> {children} </h2> ), h3: ({ children, ...props }: React.ComponentProps<"h3">) => ( <h3 className="text-lg font-semibold mt-4 mb-2" {...props}> {children} </h3> ), hr: ({ ...props }: React.ComponentProps<"hr">) => ( <hr className="my-8 border-border" {...props} /> ), li: ({ children, ...props }: React.ComponentProps<"li">) => ( <li className="mb-2 leading-relaxed text-muted-foreground text-sm pl-2" {...props} > {children} </li> ), ol: ({ children, ...props }: React.ComponentProps<"ol">) => ( <ol className="list-decimal list-outside mb-6 space-y-2 ml-6 text-muted-foreground text-sm" {...props} > {children} </ol> ), p: ({ children, ...props }: React.ComponentProps<"p">) => ( <p className="mb-6 leading-relaxed text-muted-foreground text-sm max-w-none" {...props} > {children} </p> ), pre: ({ children }: React.ComponentProps<"pre">) => ( <div className="contents">{children}</div> ), strong: ({ children, ...props }: React.ComponentProps<"strong">) => ( <strong className="font-semibold" {...props}> {children} </strong> ), ul: ({ children, ...props }: React.ComponentProps<"ul">) => ( <ul className="list-disc list-outside mb-6 space-y-2 ml-6 text-muted-foreground text-sm" {...props} > {children} </ul> ), }; const proseClasses = [ "prose prose-lg dark:prose-invert max-w-none", "prose-headings:font-bold prose-headings:tracking-tight", "prose-h1:text-2xl prose-h1:mt-8 prose-h1:mb-4", "prose-h2:text-xl prose-h2:mt-6 prose-h2:mb-3", "prose-h3:text-lg prose-h3:mt-4 prose-h3:mb-2", "prose-p:leading-relaxed prose-p:mb-6 prose-p:text-muted-foreground prose-p:text-sm prose-p:max-w-none", "prose-ul:my-6 prose-ul:ml-6 prose-ul:list-disc prose-ul:list-outside", "prose-ol:my-6 prose-ol:ml-6 prose-ol:list-decimal prose-ol:list-outside", "prose-li:mb-2 prose-li:leading-relaxed prose-li:pl-2", "prose-strong:font-semibold prose-em:italic", "prose-a:text-primary prose-a:underline prose-a:underline-offset-4 hover:prose-a:text-primary/80", "prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono", "prose-pre:my-6 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:py-4 prose-pre:font-mono prose-pre:text-sm prose-pre:text-white prose-pre:shadow-lg dark:prose-pre:bg-zinc-900", "prose-blockquote:border-l prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic prose-blockquote:text-muted-foreground prose-blockquote:my-6 prose-blockquote:py-2", "prose-hr:my-8 prose-hr:border-border", "prose-table:w-full prose-table:border-collapse prose-table:border prose-table:border-border", "prose-th:border prose-th:border-border prose-th:bg-muted prose-th:p-2 prose-th:text-left prose-th:font-medium", "prose-td:border prose-td:border-border prose-td:p-2", "prose-img:rounded-lg prose-img:border prose-img:border-border prose-img:shadow-lg", ].join(" "); const markdownPlugins = [remarkGfm]; function removeImportStatements( content: string, componentNames: string[], ): string { let processed = content.replaceAll(/^import\s+.*CodeBlock.*from.*$/gm, ""); componentNames.forEach((name) => { processed = processed.replaceAll( new RegExp(`^import\\s+.*${name}.*from.*$`, "gm"), "", ); }); return processed; } function buildCustomComponents( // eslint-disable-next-line @typescript-eslint/no-explicit-any injectedComponents: Record<string, React.ComponentType<any>>, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Record<string, React.ComponentType<any>> { return { // eslint-disable-next-line @typescript-eslint/no-explicit-any CodeBlock: CodeBlock as React.ComponentType<any>, ...injectedComponents, }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const EMPTY_MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {}; export async function MDXContent({ components = EMPTY_MDX_COMPONENTS, content, enableMDX = true, }: MDXContentProps) { const componentNames = Object.keys(components); const processedContent = removeImportStatements(content, componentNames); const contentWithoutCodeBlocks = processedContent.replaceAll( /```[\S\s]*?```/g, "", ); const hasJSX = /<[A-Z][A-Za-z]*/.test(contentWithoutCodeBlocks); const customComponents = buildCustomComponents(components); const allComponents = { ...MDXComponents, ...customComponents }; if (enableMDX && hasJSX) { // eslint-disable-next-line @typescript-eslint/no-explicit-any let Component: React.ComponentType<{ components: any }> | undefined; try { const result = await evaluate(processedContent, { ...runtime, baseUrl: import.meta.url, }); Component = result.default; } catch (error) { console.error("Error rendering MDX:", error); } if (Component) { return ( <div className={proseClasses}> <Component components={allComponents} /> </div> ); } return ( <div className={proseClasses}> <ReactMarkdown components={allComponents} remarkPlugins={markdownPlugins} > {content} </ReactMarkdown> </div> ); } return ( <div className={proseClasses}> <ReactMarkdown components={allComponents} remarkPlugins={markdownPlugins}> {content} </ReactMarkdown> </div> ); }

Dependencies

  • @vllnt/ui@^0.2.1
  • @mdx-js/mdx@^3.1.1
  • react-markdown@^10.1.0
  • remark-gfm@^4.0.1