Skip to content
Open
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
96 changes: 96 additions & 0 deletions webview-ui/src/components/common/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,29 @@ import remarkMath from "remark-math"
import remarkGfm from "remark-gfm"

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

import CodeBlock from "./CodeBlock"
import MermaidBlock from "./MermaidBlock"

// Codicon glyphs used as the leading icon for each GitHub-style alert type.
const ALERT_ICONS: Record<AlertType, string> = {
note: "codicon-info",
tip: "codicon-lightbulb",
important: "codicon-report",
warning: "codicon-warning",
caution: "codicon-flame",
}

// Human-readable label shown in the alert header.
const ALERT_LABELS: Record<AlertType, string> = {
note: "Note",
tip: "Tip",
important: "Important",
warning: "Warning",
caution: "Caution",
}

interface MarkdownBlockProps {
markdown?: string
}
Expand Down Expand Up @@ -201,6 +220,57 @@ const StyledMarkdown = styled.div`
tr:hover {
background-color: var(--vscode-list-hoverBackground);
}

/* GitHub-style Markdown alerts (#258). The accent color per type is set via
the --alert-accent custom property on the element itself. */
.markdown-alert {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 0.25em solid var(--alert-accent, var(--vscode-textBlockQuote-border));
border-radius: 3px;
background-color: var(--vscode-textBlockQuote-background);
}

.markdown-alert > :first-child {
margin-top: 0;
}

.markdown-alert > :last-child {
margin-bottom: 0;
}

.markdown-alert-title {
display: flex;
align-items: center;
gap: 0.5em;
font-weight: 600;
color: var(--alert-accent, var(--vscode-foreground));
margin-bottom: 0.25em;
}

.markdown-alert-title .codicon {
font-size: 1em;
}

.markdown-alert-note {
--alert-accent: var(--vscode-charts-blue, var(--vscode-textLink-foreground));
}

.markdown-alert-tip {
--alert-accent: var(--vscode-charts-green, var(--vscode-terminal-ansiGreen));
}

.markdown-alert-important {
--alert-accent: var(--vscode-charts-purple, var(--vscode-textLink-foreground));
}

.markdown-alert-warning {
--alert-accent: var(--vscode-charts-yellow, var(--vscode-editorWarning-foreground));
}

.markdown-alert-caution {
--alert-accent: var(--vscode-charts-red, var(--vscode-editorError-foreground));
}
`

const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
Expand Down Expand Up @@ -299,6 +369,31 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
</code>
)
},
blockquote: ({ children, className, ...props }: any) => {
// The remarkGithubAlerts plugin tags alert blockquotes with a
// `data-alert-type` attribute and `markdown-alert*` classes.
// Anything without that attribute is a normal blockquote and
// must render unchanged.
const alertType = props["data-alert-type"] as AlertType | undefined

if (!alertType || !(alertType in ALERT_ICONS)) {
return (
<blockquote className={className} {...props}>
{children}
</blockquote>
)
}

return (
<blockquote className={className} {...props}>
Comment on lines +372 to +388
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...props spreads all hast-derived attributes onto the DOM element. Today only data-alert-type and className are set by the plugin, but any future hProperty addition would silently reach the DOM. Would it be safer to destructure only the known props and discard the rest?

<div className="markdown-alert-title">
<span className={`codicon ${ALERT_ICONS[alertType]}`} aria-hidden="true" />
<span>{ALERT_LABELS[alertType]}</span>
</div>
{children}
</blockquote>
)
},
}),
[],
)
Expand All @@ -311,6 +406,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
// rendered as strikethrough; only "~~text~~" is. Matches VS Code's markdown. (#154)
[remarkGfm, { singleTilde: false }],
remarkMath,
remarkGithubAlerts,
() => {
return (tree: any) => {
visit(tree, "code", (node: any) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,80 @@ describe("MarkdownBlock", () => {
expect(screen.getByText("Step three")).toBeInTheDocument()
})

it.each([
["NOTE", "note", "codicon-info"],
["TIP", "tip", "codicon-lightbulb"],
["IMPORTANT", "important", "codicon-report"],
["WARNING", "warning", "codicon-warning"],
["CAUTION", "caution", "codicon-flame"],
])("renders a [!%s] GitHub-style alert (#258)", async (marker, type, iconClass) => {
const markdown = `> [!${marker}]\n> Body content here.`
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/Body content here/, { exact: false })

const alert = container.querySelector(`blockquote[data-alert-type="${type}"]`)
expect(alert).not.toBeNull()
expect(alert?.classList.contains("markdown-alert")).toBe(true)
expect(alert?.classList.contains(`markdown-alert-${type}`)).toBe(true)

// Distinct icon for the alert type.
expect(alert?.querySelector(`.${iconClass}`)).not.toBeNull()

// The raw "[!TYPE]" marker must not leak into the rendered text.
expect(alert?.textContent).not.toContain(`[!${marker}]`)
expect(alert?.textContent).toContain("Body content here.")
}, 10000)

it("recognizes alert markers case-insensitively", async () => {
const markdown = `> [!note]\n> lowercase marker`
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/lowercase marker/, { exact: false })

expect(container.querySelector('blockquote[data-alert-type="note"]')).not.toBeNull()
}, 10000)

it("renders alert content with inline markdown (bold, code, links)", async () => {
const markdown = `> [!WARNING]\n> Be **careful** with \`rm -rf\` and see [docs](https://example.com).`
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/careful/, { exact: false })

const alert = container.querySelector('blockquote[data-alert-type="warning"]')
expect(alert).not.toBeNull()
expect(alert?.querySelector("strong")?.textContent).toBe("careful")
expect(alert?.querySelector("code")?.textContent).toBe("rm -rf")
expect(alert?.querySelector("a")).toHaveAttribute("href", "https://example.com")
}, 10000)

it("keeps a normal blockquote rendering unchanged", async () => {
const markdown = `> Just an ordinary quote.\n> Second line.`
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/ordinary quote/, { exact: false })

const blockquote = container.querySelector("blockquote")
expect(blockquote).not.toBeNull()
expect(blockquote?.hasAttribute("data-alert-type")).toBe(false)
expect(blockquote?.classList.contains("markdown-alert")).toBe(false)
// No injected alert title/icon for normal blockquotes.
expect(blockquote?.querySelector(".markdown-alert-title")).toBeNull()
expect(blockquote?.querySelector(".codicon")).toBeNull()
}, 10000)

it("treats an unsupported marker as a normal blockquote", async () => {
const markdown = `> [!INFO]\n> Not a supported alert type.`
const { container } = render(<MarkdownBlock markdown={markdown} />)

await screen.findByText(/Not a supported alert type/, { exact: false })

const blockquote = container.querySelector("blockquote")
expect(blockquote?.hasAttribute("data-alert-type")).toBe(false)
// The raw marker text remains visible since it was not recognized.
expect(blockquote?.textContent).toContain("[!INFO]")
}, 10000)

it("should render nested lists with proper hierarchy", async () => {
const markdown = `Complex list:
1. First level ordered
Expand Down
88 changes: 87 additions & 1 deletion webview-ui/src/utils/__tests__/markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest"

import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown"
import { ALERT_TYPES, countMarkdownHeadings, hasComplexMarkdown, remarkGithubAlerts } from "../markdown"

// Minimal mdast builders so we can exercise the plugin without a full parser.
const text = (value: string) => ({ type: "text", value })
const paragraph = (...children: any[]) => ({ type: "paragraph", children })
const blockquote = (...children: any[]) => ({ type: "blockquote", children })
const root = (...children: any[]) => ({ type: "root", children })

const transform = (tree: any) => {
remarkGithubAlerts()(tree)
return tree
}

describe("markdown heading helpers", () => {
it("returns 0 for empty or undefined", () => {
Expand Down Expand Up @@ -30,3 +41,78 @@ describe("markdown heading helpers", () => {
expect(hasComplexMarkdown("# One\n## Two")).toBe(true)
})
})

describe("remarkGithubAlerts", () => {
it.each(ALERT_TYPES)("annotates a [!%s] alert blockquote", (type) => {
const upper = type.toUpperCase()
const tree = root(blockquote(paragraph(text(`[!${upper}]\nBody text`))))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does remark actually produce paragraph(text("[!NOTE]\nBody text")) — a single text node with an embedded newline? My understanding is it emits [text("[!NOTE]"), break, text("Body text")] across separate nodes. If so, this tree shape is never produced by the real parser. The integration tests in MarkdownBlock.spec.tsx do cover the real parse path, but it might be worth a quick sanity check here.


transform(tree)

const bq = tree.children[0]
expect(bq.data.hProperties["data-alert-type"]).toBe(type)
expect(bq.data.hProperties.className).toBe(`markdown-alert markdown-alert-${type}`)

// Marker is stripped from the rendered content.
expect(bq.children[0].children[0].value).toBe("Body text")
})

it("recognizes markers case-insensitively", () => {
const tree = root(blockquote(paragraph(text("[!Note]\nhi"))))

transform(tree)

expect(tree.children[0].data.hProperties["data-alert-type"]).toBe("note")
expect(tree.children[0].children[0].children[0].value).toBe("hi")
})

it("removes the marker paragraph when it has no following inline content", () => {
// `> [!NOTE]` on its own line followed by a separate paragraph.
const tree = root(blockquote(paragraph(text("[!NOTE]\n")), paragraph(text("Body on next line"))))

transform(tree)

const bq = tree.children[0]
expect(bq.data.hProperties["data-alert-type"]).toBe("note")
// The emptied marker paragraph is dropped; body paragraph remains.
expect(bq.children).toHaveLength(1)
expect(bq.children[0].children[0].value).toBe("Body on next line")
})

it("leaves a normal blockquote untouched", () => {
const tree = root(blockquote(paragraph(text("Just a quote, not an alert."))))

transform(tree)

const bq = tree.children[0]
expect(bq.data).toBeUndefined()
expect(bq.children[0].children[0].value).toBe("Just a quote, not an alert.")
})

it("ignores unsupported markers and renders them as normal blockquotes", () => {
const tree = root(blockquote(paragraph(text("[!INFO]\nNot a real alert type"))))

transform(tree)

const bq = tree.children[0]
expect(bq.data).toBeUndefined()
expect(bq.children[0].children[0].value).toBe("[!INFO]\nNot a real alert type")
})

it("does not treat a marker in the middle of text as an alert", () => {
const tree = root(blockquote(paragraph(text("Some text [!NOTE] still a quote"))))

transform(tree)

expect(tree.children[0].data).toBeUndefined()
})

it("annotates nested alert blockquotes", () => {
const tree = root(blockquote(blockquote(paragraph(text("[!TIP]\nnested")))))

transform(tree)

const inner = tree.children[0].children[0]
expect(inner.data.hProperties["data-alert-type"]).toBe("tip")
})
})
88 changes: 88 additions & 0 deletions webview-ui/src/utils/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,91 @@ export function countMarkdownHeadings(text: string | undefined): number {
export function hasComplexMarkdown(text: string | undefined): boolean {
return countMarkdownHeadings(text) >= 2
}

/**
* GitHub-style Markdown alert types, mapped to their lower-cased identifiers.
* @see https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
*/
export const ALERT_TYPES = ["note", "tip", "important", "warning", "caution"] as const

export type AlertType = (typeof ALERT_TYPES)[number]

// Matches a leading alert marker like "[!NOTE]" (case-insensitive) optionally
// followed by trailing whitespace/newline on the first line of a blockquote.
const ALERT_MARKER_REGEX = /^\[!(note|tip|important|warning|caution)\][^\S\r\n]*\r?\n?/i
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternation group here (note|tip|important|warning|caution) duplicates ALERT_TYPES — adding a new type requires updating both. Could this be derived instead?

Suggested change
const ALERT_MARKER_REGEX = /^\[!(note|tip|important|warning|caution)\][^\S\r\n]*\r?\n?/i
const ALERT_MARKER_REGEX = new RegExp(`^\\[!(${ALERT_TYPES.join("|")})\\][^\\S\\r\\n]*\\r?\\n?`, "i")


/**
* remark plugin that detects GitHub-style alerts inside blockquotes
* (e.g. `> [!NOTE]`) and annotates the blockquote node so it can be rendered
* as a distinct alert block.
*
* The marker text is stripped from the rendered content and the recognized
* alert type is exposed via the `data-alert-type` attribute plus matching
* `markdown-alert*` class names on the emitted `<blockquote>` element.
*
* Blockquotes that do not begin with a supported marker are left untouched, so
* normal blockquotes continue to render exactly as before.
*/
export function remarkGithubAlerts() {
return (tree: any) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@types/mdast is available transitively via remark-gfm, and unist-util-visit v5 is already a dependency. Should the transformer be typed as Plugin<[], Root> with Root from mdast, so callers get compile-time safety when passing this to remarkPlugins?

walkAlertBlockquotes(tree)
}
}

function walkAlertBlockquotes(node: any): void {
if (!node || typeof node !== "object") {
return
}

if (node.type === "blockquote") {
annotateAlertBlockquote(node)
}

if (Array.isArray(node.children)) {
for (const child of node.children) {
walkAlertBlockquotes(child)
}
}
Comment on lines +55 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unist-util-visit is already imported in MarkdownBlock.tsx and listed in package.json. Would visit(tree, "blockquote", annotateAlertBlockquote) work here instead of the hand-rolled walker?

}

function annotateAlertBlockquote(node: any): void {
const firstChild = node.children?.[0]

// The marker must live in the first paragraph's first text node.
if (!firstChild || firstChild.type !== "paragraph") {
return
}

const firstText = firstChild.children?.[0]

if (!firstText || firstText.type !== "text" || typeof firstText.value !== "string") {
return
}

const match = firstText.value.match(ALERT_MARKER_REGEX)

if (!match) {
return
}

const alertType = match[1].toLowerCase() as AlertType

// Strip the marker (and the following newline) from the rendered content.
firstText.value = firstText.value.slice(match[0].length)

// Drop the now-empty leading text node so the alert body starts cleanly.
if (firstText.value === "") {
firstChild.children.shift()
}

// If the paragraph became empty (marker was on its own line with no inline
// content following it), remove it entirely.
if (firstChild.children.length === 0) {
node.children.shift()
}

node.data = node.data || {}
const hProperties = (node.data.hProperties = node.data.hProperties || {})
hProperties.className = `markdown-alert markdown-alert-${alertType}`
hProperties["data-alert-type"] = alertType
}
Loading