Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions webview-ui/src/components/common/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import remarkMath from "remark-math"
import remarkGfm from "remark-gfm"

import { vscode } from "@src/utils/vscode"
import remarkGithubAlerts, { ALERT_LABELS, type AlertType } from "@src/utils/remarkGithubAlerts"

import CodeBlock from "./CodeBlock"
import MermaidBlock from "./MermaidBlock"
Expand Down Expand Up @@ -201,11 +202,94 @@ const StyledMarkdown = styled.div`
tr:hover {
background-color: var(--vscode-list-hoverBackground);
}

/* GitHub-style Markdown alert styles */
.markdown-alert {
padding: 8px 16px;
margin: 1em 0;
border-left: 4px solid;
border-radius: 2px;
background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));

> p:first-child {
margin-top: 0.25em;
}

> p:last-child {
margin-bottom: 0.25em;
}
}

.markdown-alert-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
margin-bottom: 4px;
}

.markdown-alert-title svg {
flex-shrink: 0;
}

.markdown-alert-note {
border-left-color: var(--vscode-textLink-foreground, #3794ff);
}

.markdown-alert-note .markdown-alert-title {
color: var(--vscode-textLink-foreground, #3794ff);
}

.markdown-alert-tip {
border-left-color: var(--vscode-testing-iconPassed, #73c991);
}

.markdown-alert-tip .markdown-alert-title {
color: var(--vscode-testing-iconPassed, #73c991);
}

.markdown-alert-important {
border-left-color: var(--vscode-editorInfo-foreground, #a371f7);
}

.markdown-alert-important .markdown-alert-title {
color: var(--vscode-editorInfo-foreground, #a371f7);
}

.markdown-alert-warning {
border-left-color: var(--vscode-editorWarning-foreground, #cca700);
}

.markdown-alert-warning .markdown-alert-title {
color: var(--vscode-editorWarning-foreground, #cca700);
}

.markdown-alert-caution {
border-left-color: var(--vscode-editorError-foreground, #f85149);
}

.markdown-alert-caution .markdown-alert-title {
color: var(--vscode-editorError-foreground, #f85149);
}
`

const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
const components = useMemo(
() => ({
blockquote: ({ children, className, ...props }: any) => {
const alertType = props["data-alert-type"] as string | undefined
if (!alertType) {
return <blockquote {...props}>{children}</blockquote>
}

const label = ALERT_LABELS[alertType.toUpperCase() as AlertType] || alertType
return (
<div className={className} {...props}>
<p className="markdown-alert-title">{label}</p>
{children}
</div>
)
},
table: ({ children, ...props }: any) => {
return (
<div className="table-wrapper">
Expand Down Expand Up @@ -309,6 +393,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
remarkPlugins={[
remarkGfm,
remarkMath,
remarkGithubAlerts,
() => {
return (tree: any) => {
visit(tree, "code", (node: any) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,93 @@ describe("MarkdownBlock", () => {
expect(screen.getByText("Third level ordered")).toBeInTheDocument()
expect(screen.getByText("Back to first level")).toBeInTheDocument()
})

describe("GitHub-style Markdown alerts", () => {
it("should render a NOTE alert with title and content", async () => {
const markdown = "> [!NOTE]\n> This is useful information."
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText("Note")
const alertEl = container.querySelector(".markdown-alert-note")
expect(alertEl).toBeInTheDocument()
expect(screen.getByText("Note")).toBeInTheDocument()
expect(screen.getByText(/This is useful information/)).toBeInTheDocument()
})

it("should render all five alert types", async () => {
const types = [
{ marker: "NOTE", label: "Note", cssClass: "markdown-alert-note" },
{ marker: "TIP", label: "Tip", cssClass: "markdown-alert-tip" },
{ marker: "IMPORTANT", label: "Important", cssClass: "markdown-alert-important" },
{ marker: "WARNING", label: "Warning", cssClass: "markdown-alert-warning" },
{ marker: "CAUTION", label: "Caution", cssClass: "markdown-alert-caution" },
]

for (const { marker, label, cssClass } of types) {
const markdown = `> [!${marker}]\n> Alert content for ${marker}.`
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(label)
const alertEl = container.querySelector(`.${cssClass}`)
expect(alertEl).toBeInTheDocument()
}
})

it("should render normal blockquotes unchanged", async () => {
const markdown = "> This is a normal blockquote."
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/This is a normal blockquote/)
const blockquote = container.querySelector("blockquote")
expect(blockquote).toBeInTheDocument()
// Should NOT have alert classes
const alertEl = container.querySelector(".markdown-alert")
expect(alertEl).not.toBeInTheDocument()
})

it("should render multiline alert content", async () => {
const markdown = "> [!WARNING]\n> Line one.\n> Line two.\n> Line three."
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText("Warning")
const alertEl = container.querySelector(".markdown-alert-warning")
expect(alertEl).toBeInTheDocument()
expect(container.textContent).toContain("Line one.")
expect(container.textContent).toContain("Line two.")
expect(container.textContent).toContain("Line three.")
})

it("should fall back to normal blockquote for unsupported markers", async () => {
const markdown = "> [!DANGER]\n> This is unsupported."
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/DANGER/)
const blockquote = container.querySelector("blockquote")
expect(blockquote).toBeInTheDocument()
const alertEl = container.querySelector(".markdown-alert")
expect(alertEl).not.toBeInTheDocument()
})

it("should handle alert with inline formatting", async () => {
const markdown = "> [!TIP]\n> Use `code` and **bold** text."
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText("Tip")
const alertEl = container.querySelector(".markdown-alert-tip")
expect(alertEl).toBeInTheDocument()
const codeEl = alertEl?.querySelector("code")
expect(codeEl).toBeInTheDocument()
expect(codeEl?.textContent).toBe("code")
})

it("should handle alert marker with content on the same line", async () => {
const markdown = "> [!CAUTION] Be careful!"
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText("Caution")
const alertEl = container.querySelector(".markdown-alert-caution")
expect(alertEl).toBeInTheDocument()
expect(container.textContent).toContain("Be careful!")
})
})
})
Loading
Loading