Skip to content

Commit 5fbf141

Browse files
authored
feat(remark): add alert support (#832)
1 parent 75e291a commit 5fbf141

6 files changed

Lines changed: 187 additions & 2 deletions

File tree

src/generators/jsx-ast/constants.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,30 @@ export const ALERT_LEVELS = {
1313
WARNING: 'warning',
1414
INFO: 'info',
1515
SUCCESS: 'success',
16+
NEUTRAL: 'neutral',
1617
};
1718

19+
/**
20+
* Maps GitHub alert keywords (used in `> [!NOTE]`-style blockquotes) to their
21+
* corresponding AlertBox levels.
22+
*
23+
* @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
24+
*/
25+
export const GITHUB_ALERT_TYPES = {
26+
NOTE: ALERT_LEVELS.NEUTRAL,
27+
TIP: ALERT_LEVELS.SUCCESS,
28+
IMPORTANT: ALERT_LEVELS.INFO,
29+
WARNING: ALERT_LEVELS.WARNING,
30+
CAUTION: ALERT_LEVELS.DANGER,
31+
};
32+
33+
// Matches a GitHub alert marker (e.g. `[!NOTE]`) at the very start of a
34+
// blockquote, consuming any trailing inline whitespace and the line break
35+
// that separates the marker from the alert's body.
36+
export const ALERT_MARKER = new RegExp(
37+
`^\\[!(${Object.keys(GITHUB_ALERT_TYPES).join('|')})\\][^\\S\\n]*\\n?`
38+
);
39+
1840
export const STABILITY_LEVELS = [
1941
ALERT_LEVELS.DANGER, // (0) Deprecated
2042
ALERT_LEVELS.WARNING, // (1) Experimental
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { u } from 'unist-builder';
5+
6+
import transformAlerts from '../alerts.mjs';
7+
8+
/**
9+
* Builds a blockquote whose first paragraph leads with `markerLine`, mimicking
10+
* how remark-parse represents `> [!NOTE]\n> body` (a soft break is a `\n`
11+
* inside the leading text node).
12+
*/
13+
const makeTree = (markerLine, body = 'Body text.') =>
14+
u('root', [
15+
u('blockquote', [u('paragraph', [u('text', `${markerLine}\n${body}`)])]),
16+
]);
17+
18+
const getAttr = (node, name) =>
19+
node.attributes.find(attr => attr.name === name)?.value;
20+
21+
describe('transformAlerts', () => {
22+
for (const [marker, level, title] of [
23+
['[!NOTE]', 'neutral', 'Note'],
24+
['[!TIP]', 'success', 'Tip'],
25+
['[!IMPORTANT]', 'info', 'Important'],
26+
['[!WARNING]', 'warning', 'Warning'],
27+
['[!CAUTION]', 'danger', 'Caution'],
28+
]) {
29+
it(`maps ${marker} to an AlertBox (${level})`, () => {
30+
const tree = makeTree(marker);
31+
32+
transformAlerts()(tree);
33+
34+
const alert = tree.children[0];
35+
36+
assert.equal(alert.type, 'mdxJsxFlowElement');
37+
assert.equal(alert.name, 'AlertBox');
38+
assert.equal(getAttr(alert, 'level'), level);
39+
assert.equal(getAttr(alert, 'title'), title);
40+
});
41+
}
42+
43+
it('strips the marker but keeps the alert body', () => {
44+
const tree = makeTree('[!NOTE]', 'Highlights information.');
45+
46+
transformAlerts()(tree);
47+
48+
const text = tree.children[0].children[0].children[0];
49+
50+
assert.equal(text.type, 'text');
51+
assert.equal(text.value, 'Highlights information.');
52+
});
53+
54+
it('drops the leading paragraph when the marker is alone', () => {
55+
const tree = u('root', [
56+
u('blockquote', [
57+
u('paragraph', [u('text', '[!TIP]')]),
58+
u('paragraph', [u('text', 'Second paragraph.')]),
59+
]),
60+
]);
61+
62+
transformAlerts()(tree);
63+
64+
const alert = tree.children[0];
65+
66+
assert.equal(alert.children.length, 1);
67+
assert.equal(alert.children[0].children[0].value, 'Second paragraph.');
68+
});
69+
70+
it('ignores blockquotes without an alert marker', () => {
71+
const tree = u('root', [
72+
u('blockquote', [u('paragraph', [u('text', 'Just a quote.')])]),
73+
]);
74+
75+
transformAlerts()(tree);
76+
77+
assert.equal(tree.children[0].type, 'blockquote');
78+
});
79+
80+
it('ignores unknown markers', () => {
81+
const tree = makeTree('[!FOOBAR]');
82+
83+
transformAlerts()(tree);
84+
85+
assert.equal(tree.children[0].type, 'blockquote');
86+
});
87+
});

src/generators/jsx-ast/utils/__tests__/transformer.test.mjs renamed to src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs

File renamed without changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict';
2+
3+
import { SKIP, visit } from 'unist-util-visit';
4+
5+
import { JSX_IMPORTS } from '../../../web/constants.mjs';
6+
import { ALERT_MARKER, GITHUB_ALERT_TYPES } from '../../constants.mjs';
7+
import { createJSXElement } from '../ast.mjs';
8+
9+
/**
10+
* Converts a marker keyword into a human-readable title (e.g. `NOTE` -> `Note`).
11+
* @param {string} type - The uppercase alert keyword
12+
*/
13+
const toTitle = type => type[0] + type.slice(1).toLowerCase();
14+
15+
/**
16+
* @param {import('mdast').Root} tree
17+
*/
18+
const transformer = tree => {
19+
visit(tree, 'blockquote', (node, index, parent) => {
20+
// The marker must be the leading text of the blockquote's first paragraph
21+
const paragraph = node.children[0];
22+
23+
if (paragraph?.type !== 'paragraph') {
24+
return;
25+
}
26+
27+
const text = paragraph.children[0];
28+
29+
if (text?.type !== 'text') {
30+
return;
31+
}
32+
33+
const match = text.value.match(ALERT_MARKER);
34+
35+
if (!match) {
36+
return;
37+
}
38+
39+
// Strip the marker (and its trailing line break) from the leading text,
40+
// dropping the now-empty text node — and its paragraph — if nothing remains.
41+
text.value = text.value.slice(match[0].length);
42+
43+
if (text.value === '') {
44+
paragraph.children.shift();
45+
}
46+
47+
if (paragraph.children.length === 0) {
48+
node.children.shift();
49+
}
50+
51+
parent.children[index] = createJSXElement(JSX_IMPORTS.AlertBox.name, {
52+
inline: false,
53+
children: node.children,
54+
level: GITHUB_ALERT_TYPES[match[1]],
55+
title: toTitle(match[1]),
56+
});
57+
58+
// Skip the (now detached) blockquote's children, but revisit this index so
59+
// the new AlertBox is descended into, allowing nested alerts to transform.
60+
return [SKIP, index];
61+
});
62+
};
63+
64+
/**
65+
* Remark plugin that rewrites GitHub-style alert blockquotes into AlertBox
66+
* components.
67+
*
68+
* @example
69+
* > [!NOTE]
70+
* > Highlights information that users should take into account.
71+
*
72+
* @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
73+
*/
74+
export default () => transformer;

src/generators/jsx-ast/utils/transformer.mjs renamed to src/generators/jsx-ast/utils/plugins/transformer.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { toString } from 'hast-util-to-string';
22
import { visit } from 'unist-util-visit';
33

4-
import { TAG_TRANSFORMS } from '../constants.mjs';
4+
import { TAG_TRANSFORMS } from '../../constants.mjs';
55

66
/**
77
* Checks whether a HAST node is the generated GFM footnotes section.

src/utils/remark.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { unified } from 'unified';
1515
import syntaxHighlighter, { highlighter } from './highlighter.mjs';
1616
import { lazy } from './misc.mjs';
1717
import { AST_NODE_TYPES } from '../generators/jsx-ast/constants.mjs';
18-
import transformElements from '../generators/jsx-ast/utils/transformer.mjs';
18+
import transformAlerts from '../generators/jsx-ast/utils/plugins/alerts.mjs';
19+
import transformElements from '../generators/jsx-ast/utils/plugins/transformer.mjs';
1920

2021
const passThrough = ['element', ...Object.values(AST_NODE_TYPES.MDX)];
2122

@@ -71,6 +72,7 @@ const singletonShiki = await rehypeShikiji({ highlighter });
7172
export const getRemarkRecma = lazy(() =>
7273
unified()
7374
.use(remarkParse)
75+
.use(transformAlerts)
7476
// We make Rehype ignore existing HTML nodes, and JSX nodes
7577
// as these are nodes we manually created during the generation process
7678
// We also allow dangerous HTML to be passed through, since we have HTML within our Markdown

0 commit comments

Comments
 (0)