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:
Roo Code 2026-05-05 09:12:21 +00:00
parent ad25634905
commit 6264b30f21
4 changed files with 509 additions and 0 deletions

View file

@ -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) => {

View file

@ -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!")
})
})
})

View 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")
})
})
})

View 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()}`
})
}
}