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/uiUsage
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
| Prop | Type | Description |
|---|---|---|
content | string | Markdown string to render. |
contentContainerId | string | Optional 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.