Extending The Statamic Markdown Parser for Tips and Callouts

Published January 11th, 2023
11 minute read

In my continued effort to spruce up the site, I added a way to render 🔥 tips and callouts directly from markdown based content. This is all made possible via a CommonMark extension. Turns out it's pretty easy to do because we can extend another CommonMark extension to get most of the functionality we need. Let's jump in and see how it's done!

An Example Tip

First of all, here's an example of the new "tip" block we're adding:

🔥 Example Tip Block

This is an example of the new tip block. Pretty cool, huh?

From a high level, here's how we're going to accomplish it:

  • Install the base extension (to extend from)
  • Extend the renderer to customize the HTML
  • Register our custom extension with the markdown parser
  • Implement the Tailwind CSS styles to make it look good

Installing the Base Extension

As mentioned in the Statamic docs, a great hint extension already exists. Let's install it.

1composer require ueberdosis/commonmark-hint-extension
1composer require ueberdosis/commonmark-hint-extension

This extension may work for you as-is, but I wasn't happy with the fact that it added <h2> tags for the title of tip blocks. Additionally, I wanted to add the not-prose class to the containing div to undo the Tailwind CSS typography styles so that we can style it ourselves.

Create our Custom Extension

The custom extension consists of two parts:

  • The renderer class - responsible for modifying the HTML output
  • The extension class - registers the renderer, and used to bind our markdown extension

Here's the renderer class, I placed it in app/Extensions/HintRenderer.php:

1<?php
2 
3namespace App\Extensions;
4 
5use League\CommonMark\Node\Node;
6use League\CommonMark\Renderer\ChildNodeRendererInterface;
7use League\CommonMark\Renderer\NodeRendererInterface;
8use League\CommonMark\Util\HtmlElement;
9use Ueberdosis\CommonMark\Hint;
10 
11class HintRenderer implements NodeRendererInterface
12{
13 /**
14 * Render the hint node and its children
15 */
16 public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
17 {
18 Hint::assertInstanceOf($node);
19 
20 $title = $node->getTitle();
21 $title = $title
22 ? new HtmlElement(
23 tagName: 'div',
24 attributes: ['class' => 'hint-title'],
25 contents: $title,
26 )
27 : '';
28 
29 $content = new HtmlElement(
30 tagName: 'p',
31 attributes: ['class' => 'hint-content'],
32 contents: $childRenderer->renderNodes($node->children())
33 );
34 
35 return new HtmlElement(
36 tagName: 'div',
37 attributes: $this->containerAttributes($node),
38 contents: sprintf("\n%s\n%s\n", $title, $content)
39 );
40 }
41 
42 /**
43 * The attributes for the hint's containing div
44 */
45 protected function containerAttributes($node)
46 {
47 return tap($node->data->get('attributes'), function (&$attrs) use ($node) {
48 isset($attrs['class']) ? $attrs['class'] .= ' hint' : $attrs['class'] = 'hint';
49 
50 if ($type = $node->getType()) {
51 $attrs['class'] = isset($attrs['class']) ? $attrs['class'].' ' : '';
52 $attrs['class'] .= $type;
53 }
54 
55 $attrs['class'] .= ' not-prose';
56 
57 return $attrs;
58 });
59 }
60}
1<?php
2 
3namespace App\Extensions;
4 
5use League\CommonMark\Node\Node;
6use League\CommonMark\Renderer\ChildNodeRendererInterface;
7use League\CommonMark\Renderer\NodeRendererInterface;
8use League\CommonMark\Util\HtmlElement;
9use Ueberdosis\CommonMark\Hint;
10 
11class HintRenderer implements NodeRendererInterface
12{
13 /**
14 * Render the hint node and its children
15 */
16 public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
17 {
18 Hint::assertInstanceOf($node);
19 
20 $title = $node->getTitle();
21 $title = $title
22 ? new HtmlElement(
23 tagName: 'div',
24 attributes: ['class' => 'hint-title'],
25 contents: $title,
26 )
27 : '';
28 
29 $content = new HtmlElement(
30 tagName: 'p',
31 attributes: ['class' => 'hint-content'],
32 contents: $childRenderer->renderNodes($node->children())
33 );
34 
35 return new HtmlElement(
36 tagName: 'div',
37 attributes: $this->containerAttributes($node),
38 contents: sprintf("\n%s\n%s\n", $title, $content)
39 );
40 }
41 
42 /**
43 * The attributes for the hint's containing div
44 */
45 protected function containerAttributes($node)
46 {
47 return tap($node->data->get('attributes'), function (&$attrs) use ($node) {
48 isset($attrs['class']) ? $attrs['class'] .= ' hint' : $attrs['class'] = 'hint';
49 
50 if ($type = $node->getType()) {
51 $attrs['class'] = isset($attrs['class']) ? $attrs['class'].' ' : '';
52 $attrs['class'] .= $type;
53 }
54 
55 $attrs['class'] .= ' not-prose';
56 
57 return $attrs;
58 });
59 }
60}

Most of the code is taken from the base extension's renderer, but since it's marked as final, we can't extend from it 😤. I cleaned up the code a bit to use Named Arguments, and changed the title's tagName to div instead of h2. That's it for the renderer, now let's look at the extension class.

The Hint Extension

This class is a straightforward CommonMark extension. CommonMark extensions generally contain two parts -- a parser to identify the syntax and a renderer to convert it to HTML. In this case we're able to use the parser directly from the Ueberdosis Hint Extension. Then we simply and add our custom renderer instead of the package's default renderer.

1<?php
2 
3namespace App\Extensions;
4 
5use League\CommonMark\Environment\EnvironmentBuilderInterface;
6use Ueberdosis\CommonMark\Hint;
7use Ueberdosis\CommonMark\HintExtension as BaseExtension;
8use Ueberdosis\CommonMark\HintStartParser;
9 
10class HintExtension extends BaseExtension
11{
12 public function register(EnvironmentBuilderInterface $environment): void
13 {
14 $environment
15 ->addBlockStartParser(new HintStartParser())
16 ->addRenderer(Hint::class, new HintRenderer());
17 }
18}
1<?php
2 
3namespace App\Extensions;
4 
5use League\CommonMark\Environment\EnvironmentBuilderInterface;
6use Ueberdosis\CommonMark\Hint;
7use Ueberdosis\CommonMark\HintExtension as BaseExtension;
8use Ueberdosis\CommonMark\HintStartParser;
9 
10class HintExtension extends BaseExtension
11{
12 public function register(EnvironmentBuilderInterface $environment): void
13 {
14 $environment
15 ->addBlockStartParser(new HintStartParser())
16 ->addRenderer(Hint::class, new HintRenderer());
17 }
18}

Registering the Extension

Now that we have our extension's files in place, we can add it in the AppServiceProvider. This registers the extension and makes Statamic use it automatically while parsing markdown content.

1<?php
2 
3namespace App\Providers;
4 
5use App\Extensions\HintExtension;
6use Illuminate\Support\ServiceProvider;
7use Statamic\Facades\Markdown;
8 
9class AppServiceProvider extends ServiceProvider
10{
11 /**
12 * Bootstrap any application services.
13 *
14 * @return void
15 */
16 public function boot()
17 {
18 Markdown::addExtensions(fn () => [
19 new HintExtension,
20 ]);
21 }
22}
1<?php
2 
3namespace App\Providers;
4 
5use App\Extensions\HintExtension;
6use Illuminate\Support\ServiceProvider;
7use Statamic\Facades\Markdown;
8 
9class AppServiceProvider extends ServiceProvider
10{
11 /**
12 * Bootstrap any application services.
13 *
14 * @return void
15 */
16 public function boot()
17 {
18 Markdown::addExtensions(fn () => [
19 new HintExtension,
20 ]);
21 }
22}

Finally, we can add some styling for our blocks. Here's the styles I landed on (for now):

1.hint {
2 @apply border-l-4 p-4 mb-5 shadow rounded-sm text-base;
3}
4 
5.hint.tip, .hint.important {
6 @apply border-primary-500 bg-primary-100;
7}
8 
9.hint-title {
10 @apply font-semibold;
11}
12 
13.hint-content {
14 @apply mt-1 opacity-80;
15}
1.hint {
2 @apply border-l-4 p-4 mb-5 shadow rounded-sm text-base;
3}
4 
5.hint.tip, .hint.important {
6 @apply border-primary-500 bg-primary-100;
7}
8 
9.hint-title {
10 @apply font-semibold;
11}
12 
13.hint-content {
14 @apply mt-1 opacity-80;
15}

Now we can render tip blocks using the following syntax!

1:::tip Example Tip Title
2Wow, how neat is that?!
3:::
1:::tip Example Tip Title
2Wow, how neat is that?!
3:::

Here's how it looks:

Example Tip Title

Wow, how neat is that?!

That's how I added tip blocks to markdown. Reach out if you have any questions or find any mistakes in this post. Thanks, and Happy Coding!

Enjoy this article? Follow me on Twitter for more tips, articles and links.
😢 Awww, nobody has liked or mentioned this on Twitter yet.

Join the Newsletter ❤️

A most excellent monthly newsletter with code & design tips, curated links and more!
I'll never spam you. Unsubscribe at any time.
Copyright ©2023 Austen Cameron