72 lines · 2.6 KB · TypeScript
RawDownload
/** * GitHub's blockquote alerts: a quote whose first line is `[!NOTE]` — or TIP, IMPORTANT, * WARNING, CAUTION — renders as a titled callout. They are GitHub's own extension rather than * GFM, so remark-gfm leaves the marker as literal text in the quote. This lifts the marker off * the mdast into a `data-alert` attribute for the blockquote renderer to style, and removes * the marker line itself. * * Only a marker with nothing after it on its own line counts, which is GitHub's rule: * `> [!NOTE] aside` is an ordinary quote. */ interface MarkdownAstNode {  type?: string;  value?: unknown;  data?: {    hProperties?: Record<string, unknown>;  };  children?: MarkdownAstNode[];} const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i; function readGithubAlert(node: MarkdownAstNode): void {  if (node.type !== "blockquote") return;  const paragraph = node.children?.[0];  const text = paragraph?.children?.[0];  if (paragraph?.type !== "paragraph" || text?.type !== "text" || typeof text.value !== "string") {    return;  }  const match = GITHUB_ALERT_MARKER.exec(text.value);  if (!match?.[1]) return;   const remainder = text.value.slice(match[0].length);  // Whether the marker's own line ended inside this text node. Without it, an empty remainder  // is ambiguous: `[!NOTE]\n**bold**` parses as [text "[!NOTE]\n", strong] — next-line content  // in a sibling — while `[!NOTE]*aside*` parses the same way minus the newline.  const markerEndsItsLine = match[0].endsWith("\n");  if (remainder.length > 0) {    // The quote continues on the next line inside the same text node: keep the paragraph,    // shed the marker line.    text.value = remainder;  } else if (markerEndsItsLine || paragraph.children?.length === 1) {    // The marker was the whole text node — a marker-only paragraph, or a marker line whose    // next line starts as a sibling inline. Drop the node, and the paragraph if it empties.    paragraph.children?.shift();    if (paragraph.children?.length === 0) {      node.children?.shift();    }  } else {    // Something else shares the marker's line — `[!NOTE]*aside*` — which GitHub does not    // read as an alert.    return;  }   node.data = {    ...node.data,    hProperties: {      ...node.data?.hProperties,      dataAlert: match[1].toLowerCase(),    },  };} export function remarkGithubAlerts() {  return (tree: MarkdownAstNode) => {    const visit = (node: MarkdownAstNode) => {      node.children?.forEach(visit);      readGithubAlert(node);    };    visit(tree);  };}