Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/generators/jsx-ast/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions src/generators/jsx-ast/utils/plugins/__tests__/alerts.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
});
74 changes: 74 additions & 0 deletions src/generators/jsx-ast/utils/plugins/alerts.mjs
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/utils/remark.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)];

Expand Down Expand Up @@ -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
Expand Down
Loading