1/**
2 * GitHub's blockquote alerts: a quote whose first line is `[!NOTE]` — or TIP, IMPORTANT,
3 * WARNING, CAUTION — renders as a titled callout. They are GitHub's own extension rather than
4 * GFM, so remark-gfm leaves the marker as literal text in the quote. This lifts the marker off
5 * the mdast into a `data-alert` attribute for the blockquote renderer to style, and removes
6 * the marker line itself.
7 *
8 * Only a marker with nothing after it on its own line counts, which is GitHub's rule:
9 * `> [!NOTE] aside` is an ordinary quote.
10 */
11
12interface MarkdownAstNode {
13 type?: string;
14 value?: unknown;
15 data?: {
16 hProperties?: Record<string, unknown>;
17 };
18 children?: MarkdownAstNode[];
19}
20
21const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i;
22
23function readGithubAlert(node: MarkdownAstNode): void {
24 if (node.type !== "blockquote") return;
25 const paragraph = node.children?.[0];
26 const text = paragraph?.children?.[0];
27 if (paragraph?.type !== "paragraph" || text?.type !== "text" || typeof text.value !== "string") {
28 return;
29 }
30 const match = GITHUB_ALERT_MARKER.exec(text.value);
31 if (!match?.[1]) return;
32
33 const remainder = text.value.slice(match[0].length);
34 // Whether the marker's own line ended inside this text node. Without it, an empty remainder
35 // is ambiguous: `[!NOTE]\n**bold**` parses as [text "[!NOTE]\n", strong] — next-line content
36 // in a sibling — while `[!NOTE]*aside*` parses the same way minus the newline.
37 const markerEndsItsLine = match[0].endsWith("\n");
38 if (remainder.length > 0) {
39 // The quote continues on the next line inside the same text node: keep the paragraph,
40 // shed the marker line.
41 text.value = remainder;
42 } else if (markerEndsItsLine || paragraph.children?.length === 1) {
43 // The marker was the whole text node — a marker-only paragraph, or a marker line whose
44 // next line starts as a sibling inline. Drop the node, and the paragraph if it empties.
45 paragraph.children?.shift();
46 if (paragraph.children?.length === 0) {
47 node.children?.shift();
48 }
49 } else {
50 // Something else shares the marker's line — `[!NOTE]*aside*` — which GitHub does not
51 // read as an alert.
52 return;
53 }
54
55 node.data = {
56 ...node.data,
57 hProperties: {
58 ...node.data?.hProperties,
59 dataAlert: match[1].toLowerCase(),
60 },
61 };
62}
63
64export function remarkGithubAlerts() {
65 return (tree: MarkdownAstNode) => {
66 const visit = (node: MarkdownAstNode) => {
67 node.children?.forEach(visit);
68 readGithubAlert(node);
69 };
70 visit(tree);
71 };
72}