| 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 | |
| 12 | interface MarkdownAstNode { |
| 13 | type?: string; |
| 14 | value?: unknown; |
| 15 | data?: { |
| 16 | hProperties?: Record<string, unknown>; |
| 17 | }; |
| 18 | children?: MarkdownAstNode[]; |
| 19 | } |
| 20 | |
| 21 | const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i; |
| 22 | |
| 23 | function 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 | |
| 64 | export 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 | } |