diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 2553a5e558..5dec9859c1 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -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 = { + 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 = { + note: "Note", + tip: "Tip", + important: "Important", + warning: "Warning", + caution: "Caution", +} + interface MarkdownBlockProps { markdown?: string } @@ -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) => { @@ -299,6 +369,31 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { ) }, + 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 ( +
+ {children} +
+ ) + } + + return ( +
+
+
+ {children} +
+ ) + }, }), [], ) @@ -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) => { diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 7b1183684b..25e58a0b91 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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 diff --git a/webview-ui/src/utils/__tests__/markdown.spec.ts b/webview-ui/src/utils/__tests__/markdown.spec.ts index 97b3fdaaf2..fb4c7eb8b5 100644 --- a/webview-ui/src/utils/__tests__/markdown.spec.ts +++ b/webview-ui/src/utils/__tests__/markdown.spec.ts @@ -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", () => { @@ -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`)))) + + 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") + }) +}) diff --git a/webview-ui/src/utils/markdown.ts b/webview-ui/src/utils/markdown.ts index 7a77b9866d..730456aa7b 100644 --- a/webview-ui/src/utils/markdown.ts +++ b/webview-ui/src/utils/markdown.ts @@ -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 + +/** + * 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 `
` 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) => { + 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) + } + } +} + +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 +}