Skip to content

Commit 35db652

Browse files
proyectoauraorgedelauna
authored andcommitted
feat(markdown): render GitHub-style alerts in the webview (#258)
GitHub-style alerts ([!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) were rendered as plain blockquotes, losing their semantic meaning and visual priority. Adds a focused remark transform (no new dependency) that detects a leading alert marker in a blockquote and tags it, plus a blockquote component that renders a codicon + label header and per-type accent styling using VS Code theme variables. Normal blockquotes (and unsupported markers) render unchanged. Closes #258
1 parent 45ff598 commit 35db652

4 files changed

Lines changed: 345 additions & 1 deletion

File tree

webview-ui/src/components/common/MarkdownBlock.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,29 @@ import remarkMath from "remark-math"
77
import remarkGfm from "remark-gfm"
88

99
import { vscode } from "@src/utils/vscode"
10+
import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown"
1011

1112
import CodeBlock from "./CodeBlock"
1213
import MermaidBlock from "./MermaidBlock"
1314

15+
// Codicon glyphs used as the leading icon for each GitHub-style alert type.
16+
const ALERT_ICONS: Record<AlertType, string> = {
17+
note: "codicon-info",
18+
tip: "codicon-lightbulb",
19+
important: "codicon-report",
20+
warning: "codicon-warning",
21+
caution: "codicon-flame",
22+
}
23+
24+
// Human-readable label shown in the alert header.
25+
const ALERT_LABELS: Record<AlertType, string> = {
26+
note: "Note",
27+
tip: "Tip",
28+
important: "Important",
29+
warning: "Warning",
30+
caution: "Caution",
31+
}
32+
1433
interface MarkdownBlockProps {
1534
markdown?: string
1635
}
@@ -201,6 +220,57 @@ const StyledMarkdown = styled.div`
201220
tr:hover {
202221
background-color: var(--vscode-list-hoverBackground);
203222
}
223+
224+
/* GitHub-style Markdown alerts (#258). The accent color per type is set via
225+
the --alert-accent custom property on the element itself. */
226+
.markdown-alert {
227+
margin: 1em 0;
228+
padding: 0.5em 1em;
229+
border-left: 0.25em solid var(--alert-accent, var(--vscode-textBlockQuote-border));
230+
border-radius: 3px;
231+
background-color: var(--vscode-textBlockQuote-background);
232+
}
233+
234+
.markdown-alert > :first-child {
235+
margin-top: 0;
236+
}
237+
238+
.markdown-alert > :last-child {
239+
margin-bottom: 0;
240+
}
241+
242+
.markdown-alert-title {
243+
display: flex;
244+
align-items: center;
245+
gap: 0.5em;
246+
font-weight: 600;
247+
color: var(--alert-accent, var(--vscode-foreground));
248+
margin-bottom: 0.25em;
249+
}
250+
251+
.markdown-alert-title .codicon {
252+
font-size: 1em;
253+
}
254+
255+
.markdown-alert-note {
256+
--alert-accent: var(--vscode-charts-blue, var(--vscode-textLink-foreground));
257+
}
258+
259+
.markdown-alert-tip {
260+
--alert-accent: var(--vscode-charts-green, var(--vscode-terminal-ansiGreen));
261+
}
262+
263+
.markdown-alert-important {
264+
--alert-accent: var(--vscode-charts-purple, var(--vscode-textLink-foreground));
265+
}
266+
267+
.markdown-alert-warning {
268+
--alert-accent: var(--vscode-charts-yellow, var(--vscode-editorWarning-foreground));
269+
}
270+
271+
.markdown-alert-caution {
272+
--alert-accent: var(--vscode-charts-red, var(--vscode-editorError-foreground));
273+
}
204274
`
205275

206276
const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
@@ -299,6 +369,31 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
299369
</code>
300370
)
301371
},
372+
blockquote: ({ children, className, ...props }: any) => {
373+
// The remarkGithubAlerts plugin tags alert blockquotes with a
374+
// `data-alert-type` attribute and `markdown-alert*` classes.
375+
// Anything without that attribute is a normal blockquote and
376+
// must render unchanged.
377+
const alertType = props["data-alert-type"] as AlertType | undefined
378+
379+
if (!alertType || !(alertType in ALERT_ICONS)) {
380+
return (
381+
<blockquote className={className} {...props}>
382+
{children}
383+
</blockquote>
384+
)
385+
}
386+
387+
return (
388+
<blockquote className={className} {...props}>
389+
<div className="markdown-alert-title">
390+
<span className={`codicon ${ALERT_ICONS[alertType]}`} aria-hidden="true" />
391+
<span>{ALERT_LABELS[alertType]}</span>
392+
</div>
393+
{children}
394+
</blockquote>
395+
)
396+
},
302397
}),
303398
[],
304399
)
@@ -311,6 +406,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
311406
// rendered as strikethrough; only "~~text~~" is. Matches VS Code's markdown. (#154)
312407
[remarkGfm, { singleTilde: false }],
313408
remarkMath,
409+
remarkGithubAlerts,
314410
() => {
315411
return (tree: any) => {
316412
visit(tree, "code", (node: any) => {

webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,80 @@ describe("MarkdownBlock", () => {
112112
expect(screen.getByText("Step three")).toBeInTheDocument()
113113
})
114114

115+
it.each([
116+
["NOTE", "note", "codicon-info"],
117+
["TIP", "tip", "codicon-lightbulb"],
118+
["IMPORTANT", "important", "codicon-report"],
119+
["WARNING", "warning", "codicon-warning"],
120+
["CAUTION", "caution", "codicon-flame"],
121+
])("renders a [!%s] GitHub-style alert (#258)", async (marker, type, iconClass) => {
122+
const markdown = `> [!${marker}]\n> Body content here.`
123+
const { container } = render(<MarkdownBlock markdown={markdown} />)
124+
125+
await screen.findByText(/Body content here/, { exact: false })
126+
127+
const alert = container.querySelector(`blockquote[data-alert-type="${type}"]`)
128+
expect(alert).not.toBeNull()
129+
expect(alert?.classList.contains("markdown-alert")).toBe(true)
130+
expect(alert?.classList.contains(`markdown-alert-${type}`)).toBe(true)
131+
132+
// Distinct icon for the alert type.
133+
expect(alert?.querySelector(`.${iconClass}`)).not.toBeNull()
134+
135+
// The raw "[!TYPE]" marker must not leak into the rendered text.
136+
expect(alert?.textContent).not.toContain(`[!${marker}]`)
137+
expect(alert?.textContent).toContain("Body content here.")
138+
}, 10000)
139+
140+
it("recognizes alert markers case-insensitively", async () => {
141+
const markdown = `> [!note]\n> lowercase marker`
142+
const { container } = render(<MarkdownBlock markdown={markdown} />)
143+
144+
await screen.findByText(/lowercase marker/, { exact: false })
145+
146+
expect(container.querySelector('blockquote[data-alert-type="note"]')).not.toBeNull()
147+
}, 10000)
148+
149+
it("renders alert content with inline markdown (bold, code, links)", async () => {
150+
const markdown = `> [!WARNING]\n> Be **careful** with \`rm -rf\` and see [docs](https://example.com).`
151+
const { container } = render(<MarkdownBlock markdown={markdown} />)
152+
153+
await screen.findByText(/careful/, { exact: false })
154+
155+
const alert = container.querySelector('blockquote[data-alert-type="warning"]')
156+
expect(alert).not.toBeNull()
157+
expect(alert?.querySelector("strong")?.textContent).toBe("careful")
158+
expect(alert?.querySelector("code")?.textContent).toBe("rm -rf")
159+
expect(alert?.querySelector("a")).toHaveAttribute("href", "https://example.com")
160+
}, 10000)
161+
162+
it("keeps a normal blockquote rendering unchanged", async () => {
163+
const markdown = `> Just an ordinary quote.\n> Second line.`
164+
const { container } = render(<MarkdownBlock markdown={markdown} />)
165+
166+
await screen.findByText(/ordinary quote/, { exact: false })
167+
168+
const blockquote = container.querySelector("blockquote")
169+
expect(blockquote).not.toBeNull()
170+
expect(blockquote?.hasAttribute("data-alert-type")).toBe(false)
171+
expect(blockquote?.classList.contains("markdown-alert")).toBe(false)
172+
// No injected alert title/icon for normal blockquotes.
173+
expect(blockquote?.querySelector(".markdown-alert-title")).toBeNull()
174+
expect(blockquote?.querySelector(".codicon")).toBeNull()
175+
}, 10000)
176+
177+
it("treats an unsupported marker as a normal blockquote", async () => {
178+
const markdown = `> [!INFO]\n> Not a supported alert type.`
179+
const { container } = render(<MarkdownBlock markdown={markdown} />)
180+
181+
await screen.findByText(/Not a supported alert type/, { exact: false })
182+
183+
const blockquote = container.querySelector("blockquote")
184+
expect(blockquote?.hasAttribute("data-alert-type")).toBe(false)
185+
// The raw marker text remains visible since it was not recognized.
186+
expect(blockquote?.textContent).toContain("[!INFO]")
187+
}, 10000)
188+
115189
it("should render nested lists with proper hierarchy", async () => {
116190
const markdown = `Complex list:
117191
1. First level ordered

webview-ui/src/utils/__tests__/markdown.spec.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { describe, expect, it } from "vitest"
22

3-
import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown"
3+
import { ALERT_TYPES, countMarkdownHeadings, hasComplexMarkdown, remarkGithubAlerts } from "../markdown"
4+
5+
// Minimal mdast builders so we can exercise the plugin without a full parser.
6+
const text = (value: string) => ({ type: "text", value })
7+
const paragraph = (...children: any[]) => ({ type: "paragraph", children })
8+
const blockquote = (...children: any[]) => ({ type: "blockquote", children })
9+
const root = (...children: any[]) => ({ type: "root", children })
10+
11+
const transform = (tree: any) => {
12+
remarkGithubAlerts()(tree)
13+
return tree
14+
}
415

516
describe("markdown heading helpers", () => {
617
it("returns 0 for empty or undefined", () => {
@@ -30,3 +41,78 @@ describe("markdown heading helpers", () => {
3041
expect(hasComplexMarkdown("# One\n## Two")).toBe(true)
3142
})
3243
})
44+
45+
describe("remarkGithubAlerts", () => {
46+
it.each(ALERT_TYPES)("annotates a [!%s] alert blockquote", (type) => {
47+
const upper = type.toUpperCase()
48+
const tree = root(blockquote(paragraph(text(`[!${upper}]\nBody text`))))
49+
50+
transform(tree)
51+
52+
const bq = tree.children[0]
53+
expect(bq.data.hProperties["data-alert-type"]).toBe(type)
54+
expect(bq.data.hProperties.className).toBe(`markdown-alert markdown-alert-${type}`)
55+
56+
// Marker is stripped from the rendered content.
57+
expect(bq.children[0].children[0].value).toBe("Body text")
58+
})
59+
60+
it("recognizes markers case-insensitively", () => {
61+
const tree = root(blockquote(paragraph(text("[!Note]\nhi"))))
62+
63+
transform(tree)
64+
65+
expect(tree.children[0].data.hProperties["data-alert-type"]).toBe("note")
66+
expect(tree.children[0].children[0].children[0].value).toBe("hi")
67+
})
68+
69+
it("removes the marker paragraph when it has no following inline content", () => {
70+
// `> [!NOTE]` on its own line followed by a separate paragraph.
71+
const tree = root(blockquote(paragraph(text("[!NOTE]\n")), paragraph(text("Body on next line"))))
72+
73+
transform(tree)
74+
75+
const bq = tree.children[0]
76+
expect(bq.data.hProperties["data-alert-type"]).toBe("note")
77+
// The emptied marker paragraph is dropped; body paragraph remains.
78+
expect(bq.children).toHaveLength(1)
79+
expect(bq.children[0].children[0].value).toBe("Body on next line")
80+
})
81+
82+
it("leaves a normal blockquote untouched", () => {
83+
const tree = root(blockquote(paragraph(text("Just a quote, not an alert."))))
84+
85+
transform(tree)
86+
87+
const bq = tree.children[0]
88+
expect(bq.data).toBeUndefined()
89+
expect(bq.children[0].children[0].value).toBe("Just a quote, not an alert.")
90+
})
91+
92+
it("ignores unsupported markers and renders them as normal blockquotes", () => {
93+
const tree = root(blockquote(paragraph(text("[!INFO]\nNot a real alert type"))))
94+
95+
transform(tree)
96+
97+
const bq = tree.children[0]
98+
expect(bq.data).toBeUndefined()
99+
expect(bq.children[0].children[0].value).toBe("[!INFO]\nNot a real alert type")
100+
})
101+
102+
it("does not treat a marker in the middle of text as an alert", () => {
103+
const tree = root(blockquote(paragraph(text("Some text [!NOTE] still a quote"))))
104+
105+
transform(tree)
106+
107+
expect(tree.children[0].data).toBeUndefined()
108+
})
109+
110+
it("annotates nested alert blockquotes", () => {
111+
const tree = root(blockquote(blockquote(paragraph(text("[!TIP]\nnested")))))
112+
113+
transform(tree)
114+
115+
const inner = tree.children[0].children[0]
116+
expect(inner.data.hProperties["data-alert-type"]).toBe("tip")
117+
})
118+
})

webview-ui/src/utils/markdown.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,91 @@ export function countMarkdownHeadings(text: string | undefined): number {
2121
export function hasComplexMarkdown(text: string | undefined): boolean {
2222
return countMarkdownHeadings(text) >= 2
2323
}
24+
25+
/**
26+
* GitHub-style Markdown alert types, mapped to their lower-cased identifiers.
27+
* @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
28+
*/
29+
export const ALERT_TYPES = ["note", "tip", "important", "warning", "caution"] as const
30+
31+
export type AlertType = (typeof ALERT_TYPES)[number]
32+
33+
// Matches a leading alert marker like "[!NOTE]" (case-insensitive) optionally
34+
// followed by trailing whitespace/newline on the first line of a blockquote.
35+
const ALERT_MARKER_REGEX = /^\[!(note|tip|important|warning|caution)\][^\S\r\n]*\r?\n?/i
36+
37+
/**
38+
* remark plugin that detects GitHub-style alerts inside blockquotes
39+
* (e.g. `> [!NOTE]`) and annotates the blockquote node so it can be rendered
40+
* as a distinct alert block.
41+
*
42+
* The marker text is stripped from the rendered content and the recognized
43+
* alert type is exposed via the `data-alert-type` attribute plus matching
44+
* `markdown-alert*` class names on the emitted `<blockquote>` element.
45+
*
46+
* Blockquotes that do not begin with a supported marker are left untouched, so
47+
* normal blockquotes continue to render exactly as before.
48+
*/
49+
export function remarkGithubAlerts() {
50+
return (tree: any) => {
51+
walkAlertBlockquotes(tree)
52+
}
53+
}
54+
55+
function walkAlertBlockquotes(node: any): void {
56+
if (!node || typeof node !== "object") {
57+
return
58+
}
59+
60+
if (node.type === "blockquote") {
61+
annotateAlertBlockquote(node)
62+
}
63+
64+
if (Array.isArray(node.children)) {
65+
for (const child of node.children) {
66+
walkAlertBlockquotes(child)
67+
}
68+
}
69+
}
70+
71+
function annotateAlertBlockquote(node: any): void {
72+
const firstChild = node.children?.[0]
73+
74+
// The marker must live in the first paragraph's first text node.
75+
if (!firstChild || firstChild.type !== "paragraph") {
76+
return
77+
}
78+
79+
const firstText = firstChild.children?.[0]
80+
81+
if (!firstText || firstText.type !== "text" || typeof firstText.value !== "string") {
82+
return
83+
}
84+
85+
const match = firstText.value.match(ALERT_MARKER_REGEX)
86+
87+
if (!match) {
88+
return
89+
}
90+
91+
const alertType = match[1].toLowerCase() as AlertType
92+
93+
// Strip the marker (and the following newline) from the rendered content.
94+
firstText.value = firstText.value.slice(match[0].length)
95+
96+
// Drop the now-empty leading text node so the alert body starts cleanly.
97+
if (firstText.value === "") {
98+
firstChild.children.shift()
99+
}
100+
101+
// If the paragraph became empty (marker was on its own line with no inline
102+
// content following it), remove it entirely.
103+
if (firstChild.children.length === 0) {
104+
node.children.shift()
105+
}
106+
107+
node.data = node.data || {}
108+
const hProperties = (node.data.hProperties = node.data.hProperties || {})
109+
hProperties.className = `markdown-alert markdown-alert-${alertType}`
110+
hProperties["data-alert-type"] = alertType
111+
}

0 commit comments

Comments
 (0)