Components

/

markdown

Markdown Render

A reading-first markdown renderer with copyable code blocks, GitHub-flavored markdown support, and a clean article layout.

Code

tsx
import "./markdown-render.css";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import { createHighlighter, type BuiltinLanguage, type BundledTheme } from 'shiki';
import { isInternalHref, isPlaceholderHref } from '@/lib/seo';
import MarkdownCopyButtonClient from './MarkdownCopyButtonClient';
import type { ReactNode } from 'react';

const SHIKI_THEME_LIGHT:BundledTheme = "github-light"
const SHIKI_THEME_DARK: BundledTheme = "vitesse-dark";
const SHIKI_LANGS: BuiltinLanguage[] = [
    'ts',
    'tsx',
    'typescript',
    'javascript',
    'jsx',
    'json',
    'http',
    'bash',
    'markdown',
    'html',
    'css',
    'python',
    'yaml',
];

const shikiHighlighter = await createHighlighter({
    themes: [SHIKI_THEME_LIGHT, SHIKI_THEME_DARK],
    langs: SHIKI_LANGS,
});

function rehypeShikiSync() {
    return rehypeShikiFromHighlighter(shikiHighlighter,
        {
            themes: {
                light: SHIKI_THEME_LIGHT,
                dark: SHIKI_THEME_DARK,
            },
            defaultColor: 'light-dark()',
            addLanguageClass: true,
            defaultLanguage: 'txt',
            fallbackLanguage: 'txt',
        });
}

type PostRenderProps = {
    content: string
    contentContainerId?: string
}

function getPreLanguage(node: unknown): string {
    const preNode = node as { children?: Array<{ tagName?: string; properties?: { class?: string | string[]; className?: string | string[] } }> } | undefined;
    const codeNode = (preNode?.children || []).find((child) => child?.tagName === 'code');
    const rawClass = codeNode?.properties?.class ?? codeNode?.properties?.className ?? '';
    const className = Array.isArray(rawClass) ? rawClass.join(' ') : String(rawClass || '');
    const match = /language-(\w+)/.exec(className || '');
    const language = match?.[1];
    return language === 'txt' ? '' : (language || '');
}

function extractTextContent(node: ReactNode): string {
    if (typeof node === 'string' || typeof node === 'number') return String(node);
    if (!node || typeof node !== 'object') return '';
    if (Array.isArray(node)) return node.map(extractTextContent).join('');

    const reactNode = node as { props?: { children?: ReactNode } };
    return extractTextContent(reactNode.props?.children);
}

function remarkHighlight(): any {
    return function (tree: any) {
        function walk(nodes: any[], parent: any, nodeIndex: number) {
            for (let i = 0; i < nodes.length; i++) {
                const node = nodes[i];
                if (node.children) {
                    walk(node.children, node, i);
                }
                if (node.type === 'text' && parent) {
                    const match = /==([^=\n]+)==/.exec(node.value);
                    if (!match) continue;
                    const before = node.value.slice(0, match.index);
                    const marked = match[1];
                    const after = node.value.slice(match.index + match[0].length);
                    const children: any[] = [];
                    if (before) children.push({ type: 'text', value: before });
                    children.push({ type: 'mark', children: [{ type: 'text', value: marked }], data: { hName: 'mark' } });
                    if (after) children.push({ type: 'text', value: after });
                    parent.children.splice(i, 1, ...children);
                    i += children.length - 1;
                }
            }
        }
        walk(tree.children, null, -1);
    };
}

const highlightSchema = { ...defaultSchema, tagNames: [...(defaultSchema.tagNames || []), 'mark'] };

export default function MarkdownRender({ content, contentContainerId }: PostRenderProps) {
    const siteUrl = String(import.meta.env.PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321')).trim();
    let imageIndex = 0;

    return (
        <div>
            <div id={contentContainerId} className="markdownDiv">
                <ReactMarkdown
                    children={content}
                    remarkPlugins={[remarkGfm, remarkHighlight as any]}
                    rehypePlugins={[
                        rehypeRaw,
                        [rehypeSanitize, highlightSchema],
                        rehypeShikiSync,
                    ]}
                    components={{
                        a: ({ node, ...props }) => {
                            const href = String(props.href || '').trim();
                            if (isPlaceholderHref(href)) {
                                return <span {...props}>{props.children}</span>;
                            }

                            const external = href ? !isInternalHref(href, siteUrl) : false;
                            return (
                                <a
                                    href={href}
                                    target={external ? "_blank" : undefined}
                                    rel={external ? "noopener noreferrer" : undefined}
                                    {...props}
                                />
                            );
                        },
                        img: ({ node, alt, src, ...props }) => {
                            imageIndex += 1;
                            const isFirstImage = imageIndex === 1;
                            const fallbackAlt = String(src || '')
                                .split('/')
                                .pop()
                                ?.replace(/\.[a-z0-9]+$/i, '')
                                ?.replace(/[-_]+/g, ' ')
                                ?.trim() || 'Article image';

                            return (
                                <img
                                    src={src}
                                    alt={String(alt || fallbackAlt)}
                                    loading={isFirstImage ? "eager" : "lazy"}
                                    decoding="async"
                                    fetchPriority={isFirstImage ? "high" : undefined}
                                    {...props}
                                />
                            );
                        },
                        pre: ({ node, children, ...props }) => {
                            const language = getPreLanguage(node);
                            const codeText = extractTextContent(children).replace(/\n$/, '');

                            return (
                                <div className="markdownCodeBlock">
                                    <div className="markdownCodeBlockHeader">
                                        <span className="markdownCodeBlockLanguage">{language}</span>
                                        <div className="markdownCodeBlockActions">
                                            <MarkdownCopyButtonClient codeText={codeText} />
                                        </div>
                                    </div>
                                    <pre {...props}>{children}</pre>
                                </div>
                            );
                        },
                        table: ({ node, children, ...props }) => (
                            <div className="markdownTableScrollArea">
                                <table className="markdownTable" {...props}>
                                    {children}
                                </table>
                            </div>
                        ),
                        th: ({ node, className, children, ...props }) => (
                            <th className={className} {...props}>
                                {children}
                            </th>
                        ),
                        td: ({ node, className, children, ...props }) => (
                            <td className={className} {...props}>
                                {children}
                            </td>
                        ),
                        mark: ({ node, children, ...props }) => (
                            <mark {...props}>
                                {children}
                            </mark>
                        ),
                    }} />
            </div>

        </div>)
}

Installation

bash
npx whitepapper add markdown-render --outdir src/components/ui

Usage

tsx
import MarkdownRender from "@/components/ui/markdown-render/markdown-render";

export default function ArticlePage() {
  return (
    <MarkdownRender
      content={`# Hello\n\nThis is **MarkdownRender** in action.\n\n- GitHub-flavored markdown\n- Copy buttons for fenced code blocks\n- Safe external links`}
      contentContainerId="article-content"
    />
  );
}

Props

PropTypeDescription
contentstringMarkdown string to render.
contentContainerIdstringOptional DOM id used by table-of-content components to scan the rendered content.

Notes

Use this when you want markdown content with a polished reading style, fenced code blocks, and copy buttons without wiring a separate renderer every time.

Whitepapper logo

Whitepapper

Whitepapper is a API first content platform for developers who want to publish once, distribute everywhere, and manage website content.