Implementing Markdown Alerts with CommonMark

Published on by Dasun Tharanga

3 min read

The league/commonmark package currently doesn't support alerts (also known as admonitions), but we can add support for alerts using CommonMark Extensions.

Extensions provide a way to group related parsers, renderers, and other components together with pre-defined priorities, configuration settings, and more.

In this blog post, I’ll walk you through the implementation of the alert extension that recognises all the alert types typically supported by GitHub.

To create the alert extension, we need to define the following classes.

Create a Abstract Block

This class represents the Alert block within the AST (Abstract Syntax Tree).

use League\CommonMark\Node\Block\AbstractBlock;

class Alert extends AbstractBlock
{
    public function __construct(string $type)
    {
        parent::__construct();

        $this->data->set('attributes', ['class' => ['alert', "alert-{$type}"]]);
    }
}

The $data property is a convenient way to handle various things related to the block. In this case, we only need to know the type of alert to style our HTML element using the CSS classes we parse in the attributes (e.g., alert-important).

Create a Block Start Parser

This parser helps identify where the block begins.

use League\CommonMark\Parser\Block\BlockStart;
use League\CommonMark\Parser\Block\BlockStartParserInterface;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Parser\MarkdownParserStateInterface;
use AlertParser;

class AlertStartParser implements BlockStartParserInterface
{
    public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart
    {
        if ($cursor->isIndented()) {
            return BlockStart::none();
        }

        if ($cursor->getNextNonSpaceCharacter() !== '>') {
            return BlockStart::none();
        }
				
        if (! preg_match('/\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/', $cursor->getLine(), $matches)) {
            return BlockStart::none();
        }

        $alertType = strtolower($matches[1]);

        $cursor->advanceToNextNonSpaceOrTab();
        $cursor->advanceToEnd();
        $cursor->advanceBySpaceOrTab();

        return BlockStart::of(new AlertParser($alertType))->at($cursor);
    }
}

Create a Block Continue Parser

This parser helps recognise where the block ends.

use League\CommonMark\Node\Block\AbstractBlock;
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
use League\CommonMark\Parser\Block\BlockContinue;
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
use League\CommonMark\Parser\Cursor;
use Alert;

class AlertParser extends AbstractBlockContinueParser
{
    private Alert $block;

    public function __construct(readonly string $type)
    {
        $this->block = new Alert($type);
    }

    public function getBlock(): Alert
    {
        return $this->block;
    }

    public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
    {
        if (! $cursor->isIndented() && $cursor->getNextNonSpaceCharacter() === '>') {
            $cursor->advanceToNextNonSpaceOrTab();
            $cursor->advanceBy(1);
            $cursor->advanceBySpaceOrTab();

            return BlockContinue::at($cursor);
        }

        return BlockContinue::none();
    }

    public function isContainer(): bool
    {
        return true;
    }

    public function canContain(AbstractBlock $childBlock): bool
    {
        return true;
    }
}

Create a Node Renderer

Once the AlertParser recognises the end of the block, the node renderer will render the corresponding HTML element with the relevant CSS class.

use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
use Stringable;
use Alert;

class AlertRenderer implements NodeRendererInterface
{
    public function render(Node $node, ChildNodeRendererInterface $childRenderer): Stringable
    {
        Alert::assertInstanceOf($node);

        $attrs = $node->data->get('attributes');

        $filling = $childRenderer->renderNodes($node->children());

        $innerSeparator = $childRenderer->getInnerSeparator();

        if ($filling === '') {
            return new HtmlElement('div', $attrs, $innerSeparator);
        }

        return new HtmlElement(
            'div',
            $attrs,
            $innerSeparator.$filling.$innerSeparator
        );
    }
}

Create a Extension

As I mentioned at the beginning, this class group everything in one place.

use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
use AlertStartParser;
use AlertRenderer;
use Alert;

final class AlertExtension implements ExtensionInterface
{
    public function register(EnvironmentBuilderInterface $environment): void
    {
        $environment->addBlockStartParser(new AlertStartParser, 80);
        $environment->addRenderer(Alert::class, new AlertRenderer);
    }
}

If you're using this extension in an existing project (such as one with custom extensions), make sure the priorities are set in the correct order.

Add to the Environment

Finally, It’s time to use the alert extension in your environment.

use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
use AlertExtension;

$environment = new Environment($config);
$environment->addExtension(new AlertExtension);

If you want to see a real-world usage of this extension, feel free to explore the source code of my website on GitHub. (by the way, I've named it "admonition" 😉)

Hope I saved you several hours! See you in another blog post!

Don’t forget to style your CSS classes!