Skip to content

Commit 478869e

Browse files
authored
feat: add markdown table rendering support (#6252)
1 parent 6216075 commit 478869e

File tree

1 file changed

+137
-162
lines changed

1 file changed

+137
-162
lines changed

webview-ui/src/components/common/MarkdownBlock.tsx

Lines changed: 137 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, { memo, useEffect } from "react"
2-
import { useRemark } from "react-remark"
1+
import React, { memo } from "react"
2+
import ReactMarkdown from "react-markdown"
33
import styled from "styled-components"
44
import { visit } from "unist-util-visit"
55
import rehypeKatex from "rehype-katex"
66
import remarkMath from "remark-math"
7+
import remarkGfm from "remark-gfm"
78

89
import { vscode } from "@src/utils/vscode"
910
import { useExtensionState } from "@src/context/ExtensionStateContext"
@@ -15,68 +16,6 @@ interface MarkdownBlockProps {
1516
markdown?: string
1617
}
1718

18-
/**
19-
* Custom remark plugin that converts plain URLs in text into clickable links
20-
*
21-
* The original bug: We were converting text nodes into paragraph nodes,
22-
* which broke the markdown structure because text nodes should remain as text nodes
23-
* within their parent elements (like paragraphs, list items, etc.).
24-
* This caused the entire content to disappear because the structure became invalid.
25-
*/
26-
const remarkUrlToLink = () => {
27-
return (tree: any) => {
28-
// Visit all "text" nodes in the markdown AST (Abstract Syntax Tree)
29-
visit(tree, "text", (node: any, index, parent) => {
30-
const urlRegex = /https?:\/\/[^\s<>)"]+/g
31-
const matches = node.value.match(urlRegex)
32-
33-
if (!matches || !parent) {
34-
return
35-
}
36-
37-
const parts = node.value.split(urlRegex)
38-
const children: any[] = []
39-
const cleanedMatches = matches.map((url: string) => url.replace(/[.,;:!?'"]+$/, ""))
40-
41-
parts.forEach((part: string, i: number) => {
42-
if (part) {
43-
children.push({ type: "text", value: part })
44-
}
45-
46-
if (cleanedMatches[i]) {
47-
const originalUrl = matches[i]
48-
const cleanedUrl = cleanedMatches[i]
49-
const removedPunctuation = originalUrl.substring(cleanedUrl.length)
50-
51-
// Create a proper link node with all required properties
52-
children.push({
53-
type: "link",
54-
url: cleanedUrl,
55-
title: null,
56-
children: [{ type: "text", value: cleanedUrl }],
57-
data: {
58-
hProperties: {
59-
href: cleanedUrl,
60-
},
61-
},
62-
})
63-
64-
if (removedPunctuation) {
65-
children.push({ type: "text", value: removedPunctuation })
66-
}
67-
}
68-
})
69-
70-
// Replace the original text node with our new nodes in the parent's children array.
71-
// This preserves the document structure while adding our links.
72-
parent.children.splice(index!, 1, ...children)
73-
74-
// Return SKIP to prevent visiting the newly created nodes
75-
return ["skip", index! + children.length]
76-
})
77-
}
78-
}
79-
8019
const StyledMarkdown = styled.div`
8120
code:not(pre > code) {
8221
font-family: var(--vscode-editor-font-family, monospace);
@@ -191,117 +130,153 @@ const StyledMarkdown = styled.div`
191130
text-decoration-color: var(--vscode-textLink-activeForeground);
192131
}
193132
}
133+
134+
/* Table styles for remark-gfm */
135+
table {
136+
width: 100%;
137+
border-collapse: collapse;
138+
margin: 1em 0;
139+
overflow-x: auto;
140+
display: block;
141+
}
142+
143+
th,
144+
td {
145+
border: 1px solid var(--vscode-panel-border);
146+
padding: 8px 12px;
147+
text-align: left;
148+
}
149+
150+
th {
151+
background-color: var(--vscode-editor-background);
152+
font-weight: 600;
153+
color: var(--vscode-foreground);
154+
}
155+
156+
tr:nth-child(even) {
157+
background-color: var(--vscode-editor-inactiveSelectionBackground);
158+
}
159+
160+
tr:hover {
161+
background-color: var(--vscode-list-hoverBackground);
162+
}
194163
`
195164

196165
const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
197-
const { theme } = useExtensionState()
198-
const [reactContent, setMarkdown] = useRemark({
199-
remarkPlugins: [
200-
remarkUrlToLink,
201-
remarkMath,
202-
() => {
203-
return (tree) => {
204-
visit(tree, "code", (node: any) => {
205-
if (!node.lang) {
206-
node.lang = "text"
207-
} else if (node.lang.includes(".")) {
208-
node.lang = node.lang.split(".").slice(-1)[0]
209-
}
210-
})
166+
const { theme: _theme } = useExtensionState()
167+
168+
const components = {
169+
a: ({ href, children, ...props }: any) => {
170+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
171+
// Only process file:// protocol or local file paths
172+
const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")
173+
174+
if (!isLocalPath) {
175+
return
211176
}
212-
},
213-
],
214-
rehypePlugins: [rehypeKatex as any],
215-
rehypeReactOptions: {
216-
components: {
217-
a: ({ href, children, ...props }: any) => {
218-
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
219-
// Only process file:// protocol or local file paths
220-
const isLocalPath = href.startsWith("file://") || href.startsWith("/") || !href.includes("://")
221-
222-
if (!isLocalPath) {
223-
return
224-
}
225177

226-
e.preventDefault()
178+
e.preventDefault()
227179

228-
// Handle absolute vs project-relative paths
229-
let filePath = href.replace("file://", "")
180+
// Handle absolute vs project-relative paths
181+
let filePath = href.replace("file://", "")
230182

231-
// Extract line number if present
232-
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
233-
let values = undefined
234-
if (match) {
235-
filePath = match[1]
236-
values = { line: parseInt(match[2]) }
237-
}
183+
// Extract line number if present
184+
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
185+
let values = undefined
186+
if (match) {
187+
filePath = match[1]
188+
values = { line: parseInt(match[2]) }
189+
}
238190

239-
// Add ./ prefix if needed
240-
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
241-
filePath = "./" + filePath
242-
}
191+
// Add ./ prefix if needed
192+
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
193+
filePath = "./" + filePath
194+
}
243195

244-
vscode.postMessage({
245-
type: "openFile",
246-
text: filePath,
247-
values,
248-
})
249-
}
250-
251-
return (
252-
<a {...props} href={href} onClick={handleClick}>
253-
{children}
254-
</a>
255-
)
256-
},
257-
pre: ({ node: _, children }: any) => {
258-
// Check for Mermaid diagrams first
259-
if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) {
260-
const child = children[0] as React.ReactElement<{ className?: string }>
261-
262-
if (child.props?.className?.includes("language-mermaid")) {
263-
return child
264-
}
265-
}
266-
267-
// For all other code blocks, use CodeBlock with copy button
268-
const codeNode = children?.[0]
269-
270-
if (!codeNode?.props?.children) {
271-
return null
272-
}
273-
274-
const language =
275-
(Array.isArray(codeNode.props?.className)
276-
? codeNode.props.className
277-
: [codeNode.props?.className]
278-
).map((c: string) => c?.replace("language-", ""))[0] || "javascript"
279-
280-
const rawText = codeNode.props.children[0] || ""
281-
return <CodeBlock source={rawText} language={language} />
282-
},
283-
code: (props: any) => {
284-
const className = props.className || ""
285-
286-
if (className.includes("language-mermaid")) {
287-
const codeText = String(props.children || "")
288-
return <MermaidBlock code={codeText} />
289-
}
290-
291-
return <code {...props} />
292-
},
293-
},
196+
vscode.postMessage({
197+
type: "openFile",
198+
text: filePath,
199+
values,
200+
})
201+
}
202+
203+
return (
204+
<a {...props} href={href} onClick={handleClick}>
205+
{children}
206+
</a>
207+
)
294208
},
295-
})
209+
pre: ({ children, ..._props }: any) => {
210+
// The structure from react-markdown v9 is: pre > code > text
211+
const codeEl = children as React.ReactElement
296212

297-
useEffect(() => {
298-
setMarkdown(markdown || "")
299-
}, [markdown, setMarkdown, theme])
213+
if (!codeEl || !codeEl.props) {
214+
return <pre>{children}</pre>
215+
}
216+
217+
const { className = "", children: codeChildren } = codeEl.props
218+
219+
// Get the actual code text
220+
let codeString = ""
221+
if (typeof codeChildren === "string") {
222+
codeString = codeChildren
223+
} else if (Array.isArray(codeChildren)) {
224+
codeString = codeChildren.filter((child) => typeof child === "string").join("")
225+
}
226+
227+
// Handle mermaid diagrams
228+
if (className.includes("language-mermaid")) {
229+
return (
230+
<div style={{ margin: "1em 0" }}>
231+
<MermaidBlock code={codeString} />
232+
</div>
233+
)
234+
}
235+
236+
// Extract language from className
237+
const match = /language-(\w+)/.exec(className)
238+
const language = match ? match[1] : "text"
239+
240+
// Wrap CodeBlock in a div to ensure proper separation
241+
return (
242+
<div style={{ margin: "1em 0" }}>
243+
<CodeBlock source={codeString} language={language} />
244+
</div>
245+
)
246+
},
247+
code: ({ children, className, ...props }: any) => {
248+
// This handles inline code
249+
return (
250+
<code className={className} {...props}>
251+
{children}
252+
</code>
253+
)
254+
},
255+
}
300256

301257
return (
302-
<div style={{}}>
303-
<StyledMarkdown>{reactContent}</StyledMarkdown>
304-
</div>
258+
<StyledMarkdown>
259+
<ReactMarkdown
260+
remarkPlugins={[
261+
remarkGfm,
262+
remarkMath,
263+
() => {
264+
return (tree: any) => {
265+
visit(tree, "code", (node: any) => {
266+
if (!node.lang) {
267+
node.lang = "text"
268+
} else if (node.lang.includes(".")) {
269+
node.lang = node.lang.split(".").slice(-1)[0]
270+
}
271+
})
272+
}
273+
},
274+
]}
275+
rehypePlugins={[rehypeKatex as any]}
276+
components={components}>
277+
{markdown || ""}
278+
</ReactMarkdown>
279+
</StyledMarkdown>
305280
)
306281
})
307282

0 commit comments

Comments
 (0)