mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add GitHub-style Markdown alert rendering
Implements a local remark plugin that detects [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], and [!CAUTION] markers in blockquotes and renders them as visually distinct alert blocks. - Local remarkGithubAlerts plugin visits blockquote nodes - Adds data attributes and CSS classes for alert styling - Custom blockquote component renders alert title and content - Styles use VS Code theme variables for light/dark/HC themes - Normal blockquotes remain unchanged - No new dependencies added - 32 tests covering unit and integration scenarios
This commit is contained in:
parent
ad25634905
commit
6264b30f21
4 changed files with 509 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
@ -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">
|
||||
|
|
@ -309,6 +393,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
|
|||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkMath,
|
||||
remarkGithubAlerts,
|
||||
() => {
|
||||
return (tree: any) => {
|
||||
visit(tree, "code", (node: any) => {
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
211
webview-ui/src/utils/__tests__/remarkGithubAlerts.spec.ts
Normal file
211
webview-ui/src/utils/__tests__/remarkGithubAlerts.spec.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { extractAlertType, ALERT_TYPES } from "../remarkGithubAlerts"
|
||||
|
||||
describe("remarkGithubAlerts", () => {
|
||||
describe("extractAlertType", () => {
|
||||
it("returns null for empty node", () => {
|
||||
expect(extractAlertType({ type: "blockquote", children: [] })).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for node without children", () => {
|
||||
expect(extractAlertType({ type: "blockquote" })).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for node without paragraph children", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [{ type: "code", value: "[!NOTE]" }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for paragraph without children", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [{ type: "paragraph" }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for paragraph with empty children array", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [{ type: "paragraph", children: [] }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for paragraph where first child is not text", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [{ type: "paragraph", children: [{ type: "emphasis" }] }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for text that does not match alert pattern", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [{ type: "paragraph", children: [{ type: "text", value: "Just a normal quote" }] }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for unsupported alert types", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [{ type: "paragraph", children: [{ type: "text", value: "[!DANGER] watch out" }] }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for marker not at start of text", () => {
|
||||
expect(
|
||||
extractAlertType({
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{ type: "paragraph", children: [{ type: "text", value: "Some text [!NOTE] more text" }] },
|
||||
],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
for (const alertType of ALERT_TYPES) {
|
||||
it(`detects [!${alertType}] alert type`, () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: `[!${alertType}] Some content` }],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(extractAlertType(node)).toBe(alertType)
|
||||
})
|
||||
}
|
||||
|
||||
it("is case-insensitive for alert markers", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: "[!note] Some content" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(extractAlertType(node)).toBe("NOTE")
|
||||
})
|
||||
|
||||
it("removes the marker text and leaves remaining content", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: "[!NOTE] Some content here" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
extractAlertType(node)
|
||||
expect(node.children[0].children![0].value).toBe("Some content here")
|
||||
})
|
||||
|
||||
it("handles marker as only text node with more paragraph children", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{ type: "text", value: "[!WARNING]" },
|
||||
{ type: "text", value: "More text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
extractAlertType(node)
|
||||
// First text node (the marker) should be removed
|
||||
expect(node.children[0].children!.length).toBe(1)
|
||||
expect(node.children[0].children![0].value).toBe("More text")
|
||||
})
|
||||
|
||||
it("removes break node after marker", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{ type: "text", value: "[!TIP]" },
|
||||
{ type: "break" },
|
||||
{ type: "text", value: "Content after break" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
extractAlertType(node)
|
||||
expect(node.children[0].children!.length).toBe(1)
|
||||
expect(node.children[0].children![0].value).toBe("Content after break")
|
||||
})
|
||||
|
||||
it("handles marker as only content in the only paragraph with more blockquote children", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: "[!IMPORTANT]" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: "Second paragraph" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
extractAlertType(node)
|
||||
// The first paragraph with just the marker should be removed
|
||||
expect(node.children.length).toBe(1)
|
||||
expect(node.children[0].children![0].value).toBe("Second paragraph")
|
||||
})
|
||||
|
||||
it("handles marker as only content in single paragraph", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: "[!CAUTION]" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = extractAlertType(node)
|
||||
expect(result).toBe("CAUTION")
|
||||
// Text should be emptied
|
||||
expect(node.children[0].children![0].value).toBe("")
|
||||
})
|
||||
|
||||
it("handles marker with trailing whitespace only", () => {
|
||||
const node = {
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: "[!NOTE] " }],
|
||||
},
|
||||
],
|
||||
}
|
||||
// The trailing space is part of the pattern match, so the remaining text is empty
|
||||
// which means the marker was the entire content
|
||||
const result = extractAlertType(node)
|
||||
expect(result).toBe("NOTE")
|
||||
})
|
||||
})
|
||||
})
|
||||
124
webview-ui/src/utils/remarkGithubAlerts.ts
Normal file
124
webview-ui/src/utils/remarkGithubAlerts.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { visit } from "unist-util-visit"
|
||||
|
||||
/**
|
||||
* Supported GitHub-style alert types.
|
||||
*/
|
||||
export const ALERT_TYPES = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"] as const
|
||||
export type AlertType = (typeof ALERT_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Pattern to match alert markers like [!NOTE], [!TIP], etc.
|
||||
* Must appear at the very start of text content in the first paragraph of a blockquote.
|
||||
*/
|
||||
const ALERT_PATTERN = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i
|
||||
|
||||
// Minimal AST node interfaces matching the unist/mdast shape used by remark.
|
||||
// Defined locally to avoid adding @types/mdast as a dependency.
|
||||
interface MdastNode {
|
||||
type: string
|
||||
value?: string
|
||||
children?: MdastNode[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the alert type from a blockquote node if it starts with a GitHub-style
|
||||
* alert marker. Returns the alert type and modifies the AST to remove the marker
|
||||
* text from the content.
|
||||
*
|
||||
* Returns null if the blockquote is not an alert.
|
||||
*/
|
||||
export function extractAlertType(node: MdastNode): AlertType | null {
|
||||
// The blockquote must have children
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// First child must be a paragraph
|
||||
const firstChild = node.children[0]
|
||||
if (firstChild.type !== "paragraph") {
|
||||
return null
|
||||
}
|
||||
|
||||
// The paragraph must have children
|
||||
if (!firstChild.children || firstChild.children.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// First child of paragraph must be text
|
||||
const firstInline = firstChild.children[0]
|
||||
if (firstInline.type !== "text" || typeof firstInline.value !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = ALERT_PATTERN.exec(firstInline.value)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const alertType = match[1].toUpperCase() as AlertType
|
||||
|
||||
// Remove the alert marker from the text
|
||||
const remaining = firstInline.value.slice(match[0].length)
|
||||
|
||||
if (remaining.length > 0) {
|
||||
// There's remaining text after the marker on the same line
|
||||
firstInline.value = remaining
|
||||
} else if (firstChild.children.length > 1) {
|
||||
// The marker was the entire first text node; remove it.
|
||||
firstChild.children.splice(0, 1)
|
||||
|
||||
// If the next element is a `break` node, remove it as well
|
||||
// (this handles the case where [!NOTE]\n becomes text + break + text)
|
||||
if (firstChild.children.length > 0 && firstChild.children[0].type === "break") {
|
||||
firstChild.children.splice(0, 1)
|
||||
}
|
||||
} else {
|
||||
// The marker was the only content in the paragraph.
|
||||
// If there are more children in the blockquote, remove this paragraph.
|
||||
if (node.children.length > 1) {
|
||||
node.children.splice(0, 1)
|
||||
} else {
|
||||
// Empty the text node - the alert will just show the title
|
||||
firstInline.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
return alertType
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert type display labels.
|
||||
*/
|
||||
export const ALERT_LABELS: Record<AlertType, string> = {
|
||||
NOTE: "Note",
|
||||
TIP: "Tip",
|
||||
IMPORTANT: "Important",
|
||||
WARNING: "Warning",
|
||||
CAUTION: "Caution",
|
||||
}
|
||||
|
||||
/**
|
||||
* A local remark plugin that transforms GitHub-style alert blockquotes into
|
||||
* annotated nodes for rendering. Detects [!NOTE], [!TIP], [!IMPORTANT],
|
||||
* [!WARNING], and [!CAUTION] markers and adds data attributes for styling.
|
||||
*
|
||||
* Normal blockquotes without alert markers are left unchanged.
|
||||
*/
|
||||
export default function remarkGithubAlerts() {
|
||||
return (tree: MdastNode) => {
|
||||
visit(tree as any, "blockquote", (node: any) => {
|
||||
const alertType = extractAlertType(node as MdastNode)
|
||||
if (!alertType) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add hProperties so react-markdown passes them as props to the
|
||||
// rendered blockquote element
|
||||
const data = (node.data = node.data || {})
|
||||
const hProperties = (data.hProperties = data.hProperties || {})
|
||||
hProperties["data-alert-type"] = alertType.toLowerCase()
|
||||
hProperties["className"] = `markdown-alert markdown-alert-${alertType.toLowerCase()}`
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue