feat: add markdown table rendering support (#6252)

This commit is contained in:
Daniel 2025-07-26 15:31:01 -05:00 committed by GitHub
parent 62160758fc
commit 478869e37a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,9 +1,10 @@
import React, { memo, useEffect } from "react"
import { useRemark } from "react-remark"
import React, { memo } from "react"
import ReactMarkdown from "react-markdown"
import styled from "styled-components"
import { visit } from "unist-util-visit"
import rehypeKatex from "rehype-katex"
import remarkMath from "remark-math"
import remarkGfm from "remark-gfm"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
@ -15,68 +16,6 @@ interface MarkdownBlockProps {
markdown?: string
}
/**
* Custom remark plugin that converts plain URLs in text into clickable links
*
* The original bug: We were converting text nodes into paragraph nodes,
* which broke the markdown structure because text nodes should remain as text nodes
* within their parent elements (like paragraphs, list items, etc.).
* This caused the entire content to disappear because the structure became invalid.
*/
const remarkUrlToLink = () => {
return (tree: any) => {
// Visit all "text" nodes in the markdown AST (Abstract Syntax Tree)
visit(tree, "text", (node: any, index, parent) => {
const urlRegex = /https?:\/\/[^\s<>)"]+/g
const matches = node.value.match(urlRegex)
if (!matches || !parent) {
return
}
const parts = node.value.split(urlRegex)
const children: any[] = []
const cleanedMatches = matches.map((url: string) => url.replace(/[.,;:!?'"]+$/, ""))
parts.forEach((part: string, i: number) => {
if (part) {
children.push({ type: "text", value: part })
}
if (cleanedMatches[i]) {
const originalUrl = matches[i]
const cleanedUrl = cleanedMatches[i]
const removedPunctuation = originalUrl.substring(cleanedUrl.length)
// Create a proper link node with all required properties
children.push({
type: "link",
url: cleanedUrl,
title: null,
children: [{ type: "text", value: cleanedUrl }],
data: {
hProperties: {
href: cleanedUrl,
},
},
})
if (removedPunctuation) {
children.push({ type: "text", value: removedPunctuation })
}
}
})
// Replace the original text node with our new nodes in the parent's children array.
// This preserves the document structure while adding our links.
parent.children.splice(index!, 1, ...children)
// Return SKIP to prevent visiting the newly created nodes
return ["skip", index! + children.length]
})
}
}
const StyledMarkdown = styled.div`
code:not(pre > code) {
font-family: var(--vscode-editor-font-family, monospace);
@ -191,117 +130,153 @@ const StyledMarkdown = styled.div`
text-decoration-color: var(--vscode-textLink-activeForeground);
}
}
/* Table styles for remark-gfm */
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
overflow-x: auto;
display: block;
}
th,
td {
border: 1px solid var(--vscode-panel-border);
padding: 8px 12px;
text-align: left;
}
th {
background-color: var(--vscode-editor-background);
font-weight: 600;
color: var(--vscode-foreground);
}
tr:nth-child(even) {
background-color: var(--vscode-editor-inactiveSelectionBackground);
}
tr:hover {
background-color: var(--vscode-list-hoverBackground);
}
`
const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
const { theme } = useExtensionState()
const [reactContent, setMarkdown] = useRemark({
remarkPlugins: [
remarkUrlToLink,
remarkMath,
() => {
return (tree) => {
visit(tree, "code", (node: any) => {
if (!node.lang) {
node.lang = "text"
} else if (node.lang.includes(".")) {
node.lang = node.lang.split(".").slice(-1)[0]
}
})
const { theme: _theme } = useExtensionState()
const components = {
a: ({ href, children, ...props }: any) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Only process file:// protocol or local file paths
const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")
if (!isLocalPath) {
return
}
},
],
rehypePlugins: [rehypeKatex as any],
rehypeReactOptions: {
components: {
a: ({ href, children, ...props }: any) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Only process file:// protocol or local file paths
const isLocalPath = href.startsWith("file://") || href.startsWith("/") || !href.includes("://")
if (!isLocalPath) {
return
}
e.preventDefault()
e.preventDefault()
// Handle absolute vs project-relative paths
let filePath = href.replace("file://", "")
// Handle absolute vs project-relative paths
let filePath = href.replace("file://", "")
// Extract line number if present
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
let values = undefined
if (match) {
filePath = match[1]
values = { line: parseInt(match[2]) }
}
// Extract line number if present
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
let values = undefined
if (match) {
filePath = match[1]
values = { line: parseInt(match[2]) }
}
// Add ./ prefix if needed
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
filePath = "./" + filePath
}
// Add ./ prefix if needed
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
filePath = "./" + filePath
}
vscode.postMessage({
type: "openFile",
text: filePath,
values,
})
}
vscode.postMessage({
type: "openFile",
text: filePath,
values,
})
}
return (
<a {...props} href={href} onClick={handleClick}>
{children}
</a>
)
},
pre: ({ node: _, children }: any) => {
// Check for Mermaid diagrams first
if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) {
const child = children[0] as React.ReactElement<{ className?: string }>
if (child.props?.className?.includes("language-mermaid")) {
return child
}
}
// For all other code blocks, use CodeBlock with copy button
const codeNode = children?.[0]
if (!codeNode?.props?.children) {
return null
}
const language =
(Array.isArray(codeNode.props?.className)
? codeNode.props.className
: [codeNode.props?.className]
).map((c: string) => c?.replace("language-", ""))[0] || "javascript"
const rawText = codeNode.props.children[0] || ""
return <CodeBlock source={rawText} language={language} />
},
code: (props: any) => {
const className = props.className || ""
if (className.includes("language-mermaid")) {
const codeText = String(props.children || "")
return <MermaidBlock code={codeText} />
}
return <code {...props} />
},
},
return (
<a {...props} href={href} onClick={handleClick}>
{children}
</a>
)
},
})
pre: ({ children, ..._props }: any) => {
// The structure from react-markdown v9 is: pre > code > text
const codeEl = children as React.ReactElement
useEffect(() => {
setMarkdown(markdown || "")
}, [markdown, setMarkdown, theme])
if (!codeEl || !codeEl.props) {
return <pre>{children}</pre>
}
const { className = "", children: codeChildren } = codeEl.props
// Get the actual code text
let codeString = ""
if (typeof codeChildren === "string") {
codeString = codeChildren
} else if (Array.isArray(codeChildren)) {
codeString = codeChildren.filter((child) => typeof child === "string").join("")
}
// Handle mermaid diagrams
if (className.includes("language-mermaid")) {
return (
<div style={{ margin: "1em 0" }}>
<MermaidBlock code={codeString} />
</div>
)
}
// Extract language from className
const match = /language-(\w+)/.exec(className)
const language = match ? match[1] : "text"
// Wrap CodeBlock in a div to ensure proper separation
return (
<div style={{ margin: "1em 0" }}>
<CodeBlock source={codeString} language={language} />
</div>
)
},
code: ({ children, className, ...props }: any) => {
// This handles inline code
return (
<code className={className} {...props}>
{children}
</code>
)
},
}
return (
<div style={{}}>
<StyledMarkdown>{reactContent}</StyledMarkdown>
</div>
<StyledMarkdown>
<ReactMarkdown
remarkPlugins={[
remarkGfm,
remarkMath,
() => {
return (tree: any) => {
visit(tree, "code", (node: any) => {
if (!node.lang) {
node.lang = "text"
} else if (node.lang.includes(".")) {
node.lang = node.lang.split(".").slice(-1)[0]
}
})
}
},
]}
rehypePlugins={[rehypeKatex as any]}
components={components}>
{markdown || ""}
</ReactMarkdown>
</StyledMarkdown>
)
})