/** * MarkdownPreview - Renders markdown with syntax highlighting. * * Uses react-markdown with shiki syntax highlighting via CodeBlock. * Matches the "Scholarly Dusk" design system. */ import type { ComponentProps, FC, ReactNode } from "react"; import { ExternalLinkIcon } from "lucide-react"; import { memo } from "react"; import ReactMarkdown from "react-markdown"; import rehypeSanitize from "rehype-sanitize"; import remarkGfm from "remark-gfm"; import { normalizeWikiName, parseTargetParts, stripWikiMdExt, } from "../../../../core/links"; import { slugifySectionTitle } from "../../../../core/sections"; import { extractMarkdownCodeLanguage, resolveCodeLanguage, } from "../../lib/code-language"; import { buildDocDeepLink } from "../../lib/deep-links"; import { cn } from "../../lib/utils"; import { CodeBlock, CodeBlockCopyButton } from "../ai-elements/code-block"; export interface MarkdownPreviewProps { /** Markdown content to render */ content: string; /** Additional CSS classes */ className?: string; /** Current collection for wiki-link resolution */ collection?: string; /** Resolved outgoing wiki links for the current document */ wikiLinks?: Array<{ targetRef: string; targetCollection?: string; targetAnchor?: string; resolvedUri?: string; }>; /** Current document URI for resolving note-relative assets */ docUri?: string; } const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g; const EXTERNAL_OR_APP_SCHEME_REGEX = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i; const ABSOLUTE_FILESYSTEM_PATH_REGEX = /^(?:\/(?:Users|home|var|tmp|private|Volumes)\/|[A-Za-z]:[\\/])/; function resolveMarkdownAssetSrc( src: string | undefined, docUri?: string ): string | undefined { if (!src) { return src; } const trimmed = src.trim(); if (trimmed.length === 0) { return trimmed; } if (EXTERNAL_OR_APP_SCHEME_REGEX.test(trimmed)) { return trimmed; } if (ABSOLUTE_FILESYSTEM_PATH_REGEX.test(trimmed)) { return `/api/doc-asset?path=${encodeURIComponent(trimmed)}`; } if (trimmed.startsWith("/")) { return trimmed; } if (!docUri) { return trimmed; } return `/api/doc-asset?uri=${encodeURIComponent(docUri)}&path=${encodeURIComponent(trimmed)}`; } function renderMarkdownWithWikiLinks( content: string, collection?: string, wikiLinks?: MarkdownPreviewProps["wikiLinks"] ): string { if (!content.includes("[[")) { return content; } const resolvedWikiLinkMap = new Map(); for (const link of wikiLinks ?? []) { const targetCollection = link.targetCollection || collection || ""; const targetRefKey = normalizeWikiName(stripWikiMdExt(link.targetRef)); const targetAnchorKey = (link.targetAnchor ?? "").trim().toLowerCase(); const key = `${targetCollection}::${targetRefKey}::${targetAnchorKey}`; if (link.resolvedUri) { resolvedWikiLinkMap.set(key, buildDocDeepLink({ uri: link.resolvedUri })); } } return content.replace(WIKI_LINK_REGEX, (match, rawContent: string) => { const [rawTarget, rawAlias] = rawContent.split("|"); const displayText = rawAlias?.trim() || rawTarget?.trim() || match; const parsed = parseTargetParts(rawTarget ?? ""); const targetCollection = parsed.collection || collection || ""; const targetRefKey = normalizeWikiName(stripWikiMdExt(parsed.ref)); const targetAnchorKey = (parsed.anchor ?? "").trim().toLowerCase(); const key = `${targetCollection}::${targetRefKey}::${targetAnchorKey}`; const href = resolvedWikiLinkMap.get(key) || `/search?query=${encodeURIComponent(stripWikiMdExt(parsed.ref))}`; return `[${displayText}](${href})`; }); } // Inline code styling // Note: Destructure `node` to prevent react-markdown from leaking it to DOM const InlineCode: FC & { node?: unknown }> = ({ className, children, node: _node, ...props }) => ( {children} ); // Link handling - external links open in new tab const Link: FC & { node?: unknown }> = ({ href, children, className, node: _node, ...props }) => { const isExternal = href?.startsWith("http"); return ( {children} {isExternal && } ); }; function flattenNodeText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); } if (!node || typeof node === "boolean") { return ""; } if (Array.isArray(node)) { return node.map((child) => flattenNodeText(child)).join(""); } if ( typeof node === "object" && "props" in node && node.props && typeof node.props === "object" && "children" in node.props ) { return flattenNodeText(node.props.children as ReactNode); } return ""; } // Heading styles with proper hierarchy const createHeading = ( level: 1 | 2 | 3 | 4 | 5 | 6, anchorCounts: Map ): FC<{ children?: ReactNode }> => ({ children }) => { const Tag = `h${level}` as const; const sizes = { 1: "text-3xl mt-8 mb-4 pb-2 border-b border-border/50", 2: "text-2xl mt-6 mb-3 pb-1.5 border-b border-border/30", 3: "text-xl mt-5 mb-2", 4: "text-lg mt-4 mb-2", 5: "text-base mt-3 mb-1 font-semibold", 6: "text-sm mt-3 mb-1 font-semibold text-muted-foreground", }; const baseAnchor = slugifySectionTitle(flattenNodeText(children)); const seen = (anchorCounts.get(baseAnchor) ?? 0) + 1; anchorCounts.set(baseAnchor, seen); const anchor = seen === 1 ? baseAnchor : `${baseAnchor}-${seen}`; return ( {children} ); }; // Code block with syntax highlighting const Pre: FC> = ({ children, ...props }) => { // Extract code element from children const codeElement = children as React.ReactElement<{ className?: string; children?: string; }>; if (!codeElement?.props) { return
{children}
; } const className = codeElement.props.className ?? ""; const code = String(codeElement.props.children ?? "").trim(); // Extract language from className (e.g., "language-typescript") const language = resolveCodeLanguage(extractMarkdownCodeLanguage(className)); return (
); }; // Blockquote with refined scholarly styling const Blockquote: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => (
p]:mb-0", className )} {...props} > {children}
); // List styles const UnorderedList: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => (
    li]:pl-1", className)} {...props} > {children}
); const OrderedList: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => (
    li]:pl-1", className)} {...props} > {children}
); // Table styles - refined scholarly aesthetic // Note: Destructure `node` to prevent react-markdown from leaking it to DOM const Table: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => (
{children}
); const TableHead: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => ( {children} ); const TableRow: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => ( {children} ); const TableCell: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => ( {children} ); const TableHeaderCell: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => ( {children} ); // Horizontal rule const Hr: FC = () =>
; // Paragraph const Paragraph: FC & { node?: unknown }> = ({ children, className, node: _node, ...props }) => (

{children}

); // Image with proper styling const Image: FC & { node?: unknown }> = ({ alt, className, node: _node, src, ...props }) => ( {alt ); /** * Renders markdown content with syntax highlighting and proper styling. * Sanitizes HTML to prevent XSS attacks. */ export const MarkdownPreview = memo( ({ content, className, collection, wikiLinks, docUri, }: MarkdownPreviewProps) => { if (!content) { return (
No content to display
); } const renderedContent = renderMarkdownWithWikiLinks( content, collection, wikiLinks ); const anchorCounts = new Map(); const components = { h1: createHeading(1, anchorCounts), h2: createHeading(2, anchorCounts), h3: createHeading(3, anchorCounts), h4: createHeading(4, anchorCounts), h5: createHeading(5, anchorCounts), h6: createHeading(6, anchorCounts), p: Paragraph, a: Link, code: InlineCode, pre: Pre, blockquote: Blockquote, ul: UnorderedList, ol: OrderedList, table: Table, thead: TableHead, tr: TableRow, td: TableCell, th: TableHeaderCell, hr: Hr, img: ({ node, src, ...props }: ComponentProps<"img"> & { node?: unknown }) => ( ), }; return (
*:first-child]:mt-0", className )} > {renderedContent}
); } ); MarkdownPreview.displayName = "MarkdownPreview";