From 7818956a36f5b2f33f0aab200169858953af8f93 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 6 Jun 2026 16:17:39 -0400 Subject: [PATCH] feat(remark): add alert support --- src/generators/jsx-ast/constants.mjs | 22 +++++ .../utils/plugins/__tests__/alerts.test.mjs | 87 +++++++++++++++++++ .../__tests__/transformer.test.mjs | 0 .../jsx-ast/utils/plugins/alerts.mjs | 74 ++++++++++++++++ .../utils/{ => plugins}/transformer.mjs | 2 +- src/utils/remark.mjs | 4 +- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/generators/jsx-ast/utils/plugins/__tests__/alerts.test.mjs rename src/generators/jsx-ast/utils/{ => plugins}/__tests__/transformer.test.mjs (100%) create mode 100644 src/generators/jsx-ast/utils/plugins/alerts.mjs rename src/generators/jsx-ast/utils/{ => plugins}/transformer.mjs (97%) diff --git a/src/generators/jsx-ast/constants.mjs b/src/generators/jsx-ast/constants.mjs index ddf407d6..55d447e4 100644 --- a/src/generators/jsx-ast/constants.mjs +++ b/src/generators/jsx-ast/constants.mjs @@ -13,8 +13,30 @@ export const ALERT_LEVELS = { WARNING: 'warning', INFO: 'info', SUCCESS: 'success', + NEUTRAL: 'neutral', }; +/** + * Maps GitHub alert keywords (used in `> [!NOTE]`-style blockquotes) to their + * corresponding AlertBox levels. + * + * @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 GITHUB_ALERT_TYPES = { + NOTE: ALERT_LEVELS.NEUTRAL, + TIP: ALERT_LEVELS.SUCCESS, + IMPORTANT: ALERT_LEVELS.INFO, + WARNING: ALERT_LEVELS.WARNING, + CAUTION: ALERT_LEVELS.DANGER, +}; + +// Matches a GitHub alert marker (e.g. `[!NOTE]`) at the very start of a +// blockquote, consuming any trailing inline whitespace and the line break +// that separates the marker from the alert's body. +export const ALERT_MARKER = new RegExp( + `^\\[!(${Object.keys(GITHUB_ALERT_TYPES).join('|')})\\][^\\S\\n]*\\n?` +); + export const STABILITY_LEVELS = [ ALERT_LEVELS.DANGER, // (0) Deprecated ALERT_LEVELS.WARNING, // (1) Experimental diff --git a/src/generators/jsx-ast/utils/plugins/__tests__/alerts.test.mjs b/src/generators/jsx-ast/utils/plugins/__tests__/alerts.test.mjs new file mode 100644 index 00000000..41fa4bf3 --- /dev/null +++ b/src/generators/jsx-ast/utils/plugins/__tests__/alerts.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import transformAlerts from '../alerts.mjs'; + +/** + * Builds a blockquote whose first paragraph leads with `markerLine`, mimicking + * how remark-parse represents `> [!NOTE]\n> body` (a soft break is a `\n` + * inside the leading text node). + */ +const makeTree = (markerLine, body = 'Body text.') => + u('root', [ + u('blockquote', [u('paragraph', [u('text', `${markerLine}\n${body}`)])]), + ]); + +const getAttr = (node, name) => + node.attributes.find(attr => attr.name === name)?.value; + +describe('transformAlerts', () => { + for (const [marker, level, title] of [ + ['[!NOTE]', 'neutral', 'Note'], + ['[!TIP]', 'success', 'Tip'], + ['[!IMPORTANT]', 'info', 'Important'], + ['[!WARNING]', 'warning', 'Warning'], + ['[!CAUTION]', 'danger', 'Caution'], + ]) { + it(`maps ${marker} to an AlertBox (${level})`, () => { + const tree = makeTree(marker); + + transformAlerts()(tree); + + const alert = tree.children[0]; + + assert.equal(alert.type, 'mdxJsxFlowElement'); + assert.equal(alert.name, 'AlertBox'); + assert.equal(getAttr(alert, 'level'), level); + assert.equal(getAttr(alert, 'title'), title); + }); + } + + it('strips the marker but keeps the alert body', () => { + const tree = makeTree('[!NOTE]', 'Highlights information.'); + + transformAlerts()(tree); + + const text = tree.children[0].children[0].children[0]; + + assert.equal(text.type, 'text'); + assert.equal(text.value, 'Highlights information.'); + }); + + it('drops the leading paragraph when the marker is alone', () => { + const tree = u('root', [ + u('blockquote', [ + u('paragraph', [u('text', '[!TIP]')]), + u('paragraph', [u('text', 'Second paragraph.')]), + ]), + ]); + + transformAlerts()(tree); + + const alert = tree.children[0]; + + assert.equal(alert.children.length, 1); + assert.equal(alert.children[0].children[0].value, 'Second paragraph.'); + }); + + it('ignores blockquotes without an alert marker', () => { + const tree = u('root', [ + u('blockquote', [u('paragraph', [u('text', 'Just a quote.')])]), + ]); + + transformAlerts()(tree); + + assert.equal(tree.children[0].type, 'blockquote'); + }); + + it('ignores unknown markers', () => { + const tree = makeTree('[!FOOBAR]'); + + transformAlerts()(tree); + + assert.equal(tree.children[0].type, 'blockquote'); + }); +}); diff --git a/src/generators/jsx-ast/utils/__tests__/transformer.test.mjs b/src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs similarity index 100% rename from src/generators/jsx-ast/utils/__tests__/transformer.test.mjs rename to src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs diff --git a/src/generators/jsx-ast/utils/plugins/alerts.mjs b/src/generators/jsx-ast/utils/plugins/alerts.mjs new file mode 100644 index 00000000..1abe1940 --- /dev/null +++ b/src/generators/jsx-ast/utils/plugins/alerts.mjs @@ -0,0 +1,74 @@ +'use strict'; + +import { SKIP, visit } from 'unist-util-visit'; + +import { JSX_IMPORTS } from '../../../web/constants.mjs'; +import { ALERT_MARKER, GITHUB_ALERT_TYPES } from '../../constants.mjs'; +import { createJSXElement } from '../ast.mjs'; + +/** + * Converts a marker keyword into a human-readable title (e.g. `NOTE` -> `Note`). + * @param {string} type - The uppercase alert keyword + */ +const toTitle = type => type[0] + type.slice(1).toLowerCase(); + +/** + * @param {import('mdast').Root} tree + */ +const transformer = tree => { + visit(tree, 'blockquote', (node, index, parent) => { + // The marker must be the leading text of the blockquote's first paragraph + const paragraph = node.children[0]; + + if (paragraph?.type !== 'paragraph') { + return; + } + + const text = paragraph.children[0]; + + if (text?.type !== 'text') { + return; + } + + const match = text.value.match(ALERT_MARKER); + + if (!match) { + return; + } + + // Strip the marker (and its trailing line break) from the leading text, + // dropping the now-empty text node — and its paragraph — if nothing remains. + text.value = text.value.slice(match[0].length); + + if (text.value === '') { + paragraph.children.shift(); + } + + if (paragraph.children.length === 0) { + node.children.shift(); + } + + parent.children[index] = createJSXElement(JSX_IMPORTS.AlertBox.name, { + inline: false, + children: node.children, + level: GITHUB_ALERT_TYPES[match[1]], + title: toTitle(match[1]), + }); + + // Skip the (now detached) blockquote's children, but revisit this index so + // the new AlertBox is descended into, allowing nested alerts to transform. + return [SKIP, index]; + }); +}; + +/** + * Remark plugin that rewrites GitHub-style alert blockquotes into AlertBox + * components. + * + * @example + * > [!NOTE] + * > Highlights information that users should take into account. + * + * @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 default () => transformer; diff --git a/src/generators/jsx-ast/utils/transformer.mjs b/src/generators/jsx-ast/utils/plugins/transformer.mjs similarity index 97% rename from src/generators/jsx-ast/utils/transformer.mjs rename to src/generators/jsx-ast/utils/plugins/transformer.mjs index 53dadee7..7d46e3ab 100644 --- a/src/generators/jsx-ast/utils/transformer.mjs +++ b/src/generators/jsx-ast/utils/plugins/transformer.mjs @@ -1,7 +1,7 @@ import { toString } from 'hast-util-to-string'; import { visit } from 'unist-util-visit'; -import { TAG_TRANSFORMS } from '../constants.mjs'; +import { TAG_TRANSFORMS } from '../../constants.mjs'; /** * Checks whether a HAST node is the generated GFM footnotes section. diff --git a/src/utils/remark.mjs b/src/utils/remark.mjs index 1dcadc19..61b5ba51 100644 --- a/src/utils/remark.mjs +++ b/src/utils/remark.mjs @@ -15,7 +15,8 @@ import { unified } from 'unified'; import syntaxHighlighter, { highlighter } from './highlighter.mjs'; import { lazy } from './misc.mjs'; import { AST_NODE_TYPES } from '../generators/jsx-ast/constants.mjs'; -import transformElements from '../generators/jsx-ast/utils/transformer.mjs'; +import transformAlerts from '../generators/jsx-ast/utils/plugins/alerts.mjs'; +import transformElements from '../generators/jsx-ast/utils/plugins/transformer.mjs'; const passThrough = ['element', ...Object.values(AST_NODE_TYPES.MDX)]; @@ -71,6 +72,7 @@ const singletonShiki = await rehypeShikiji({ highlighter }); export const getRemarkRecma = lazy(() => unified() .use(remarkParse) + .use(transformAlerts) // We make Rehype ignore existing HTML nodes, and JSX nodes // as these are nodes we manually created during the generation process // We also allow dangerous HTML to be passed through, since we have HTML within our Markdown