Rendering Blade components in Markdown

November 30, 2023

This site you're reading here is a basic Laravel application with only a few additional packages. All of the articles (this one included!) are written in markdown and then rendered with the CommonMark PHP library using this Laravel shim.

I like the writing experience and simplicity of markdown, but sometimes I need to break out of markdown and use something a bit fancier, like this tweet embed:

Aaron Francis

@aarondfrancis

The best part about being 90% done with a project is that you're almost halfway finished!

That tweet embed is a blade component that I can use in .blade files like this:

<x-tweet url='https://twitter.com/aarondfrancis/status/1705211030882684946'>
The best part about being 90% done with a project is that you're almost halfway finished!
</x-tweet>
Code highlighting powered by torchlight.dev (A service I created!)

In my ideal world, I could throw it into the .md file like this, and everything would work!

Some content here
 
<x-tweet url='https://twitter.com/aarondfrancis/status/1705211030882684946'>
The best part about being 90% done with a project is that you're almost halfway finished!
</x-tweet>
 
some more content here

But, the world being unideal, that won't work. It won't work because the markdown pipeline does not include any Blade rendering. But everything is fixable with enough effort! I've tried several methods, each with its pros and cons. I've finally settled on two equally viable ways, which you choose is a matter of preference.

They are:

  • Parse everything except code blocks
  • Parse only code blocks

Parse everything except code blocks

The first method is:

  1. Remove all code blocks from the markdown document (more on this below)
  2. Render the markdown to HTML
  3. Render the HTML through Blade
  4. Render the code blocks to HTML, separately
  5. Put the rendered code blocks back into the final document

When Commonmark parses the markdown document, it turns it into an abstract syntax tree full of nodes. We can loop through the nodes before they're turned into HTML and do a bit of modification. In our case, we'll remove all code nodes from the document.

We have to remove the code nodes from the document because those are not safe to run through the Blade parser.

Imagine, for example, you had a code block that had the following code in it:

@php(DB::table('users')->delete())

You certainly wouldn't want that being run!

Or perhaps worse:

@dd(file_get_contents(base_path('.env'))

These are worst-case scenarios, but even relatively normal stuff can cause problems. If you even include an inline-block with a Blade directive, like @endif or @include, Blade would pick those up and try to render them.

Looking for code blocks

Instead of looking for code blocks in HTML, which can be tricky, we're going to pull the code blocks out during the AST stage, which is super straightforward.

To start, we'll create a Commonmark extension called BladeParsingExtension. We'll hook into two events:

  • Document Parsed: after the markdown has been turned into an AST.
  • Document Rendered: after the AST has been turned into HTML.
class BladeParsingExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addEventListener(
DocumentParsedEvent::class, [$this, 'onDocumentParsed'], -10
);
 
$environment->addEventListener(
DocumentRenderedEvent::class, [$this, 'onDocumentRendered'], 10
);
}
 
public function onDocumentParsed(DocumentParsedEvent $event)
{
// Walk through every node in the document
foreach ($event->getDocument()->iterator() as $node) {
// We're only looking for code nodes.
if (!$this->isCodeNode($node)) {
continue;
}
 
// @TODO something???
}
}
 
public function onDocumentRendered(DocumentRenderedEvent $event)
{
// @TODO something???
}
 
protected function isCodeNode($node)
{
return $node instanceof FencedCode
|| $node instanceof IndentedCode
|| $node instanceof Code;
}
}

Removing code blocks

When we find a code node, we will pull it out of the AST and leave behind a placeholder. The placeholder is just a random string that we look for later.

class BladeParsingExtension implements ExtensionInterface
{
protected array $rendered = [];
 
protected Environment $environment;
 
public function register(EnvironmentBuilderInterface $environment): void ...
{
$environment->addEventListener(
DocumentParsedEvent::class, [$this, 'onDocumentParsed'], -10
);
 
$environment->addEventListener(
DocumentRenderedEvent::class, [$this, 'onDocumentRendered'], 10
);
 
$this->environment = $environment;
}
 
public function onDocumentParsed(DocumentParsedEvent $event)
{
foreach ($event->getDocument()->iterator() as $node) {
if (!$this->isCodeNode($node)) {
continue;
}
 
// Create a unique, random ID
$id = Str::uuid()->toString();
 
// Create a new HTML block that just has our placeholder
$replacement = new HtmlBlock(HtmlBlock::TYPE_6_BLOCK_ELEMENT);
$replacement->setLiteral("[[replace:$id]]");
 
// Replace the code node with our placeholder
$node->replaceWith($replacement);
 
// Create an identical renderer to the main one
$renderer = new HtmlRenderer($this->environment)
 
// Render the code node and stash it away.
$this->rendered[$id] = $renderer->renderNodes([$node]);
}
}
 
public function onDocumentRendered(DocumentRenderedEvent $event) ...
{
// @TODO
}
 
protected function isCodeNode($node) ...
{
return $node instanceof FencedCode
|| $node instanceof IndentedCode
|| $node instanceof Code;
}
}
Code highlighting powered by torchlight.dev (A service I created!)

We've removed and rendered the code blocks, so we're safe to run the rest of the document through Blade.

Rendering through Blade

We'll do our Blade rendering after the document has been rendered to HTML, so we hook into the DocumentRendered event. After rendering the HTML through Blade, we look for our placeholders and swap in the rendered code blocks.

class BladeParsingExtension implements ExtensionInterface
{
protected Environment $environment;
 
protected array $rendered = [];
 
public function register(EnvironmentBuilderInterface $environment): void ...
{
$environment->addEventListener(
DocumentParsedEvent::class, [$this, 'onDocumentParsed'], -10
);
 
$environment->addEventListener(
DocumentRenderedEvent::class, [$this, 'onDocumentRendered'], 10
);
 
$this->environment = $environment;
}
 
public function onDocumentParsed(DocumentParsedEvent $event) ...
{
foreach ($event->getDocument()->iterator() as $node) {
if (!$this->isCodeNode($node)) {
continue;
}
 
// Create a unique, random ID
$id = Str::uuid()->toString();
 
// Create a new HTML block that just has our placeholder
$replacement = new HtmlBlock(HtmlBlock::TYPE_6_BLOCK_ELEMENT);
$replacement->setLiteral("[[replace:$id]]");
 
// Replace the code node with our placeholder
$node->replaceWith($replacement);
 
// Create an identical renderer to the main one
$renderer = new HtmlRenderer($this->environment)
 
// Render the code node and stash it away.
$this->rendered[$id] = $renderer->renderNodes([$node]);
}
}
 
public function onDocumentRendered(DocumentRenderedEvent $event)
{
$search = [];
$replace = [];
 
// Gather up all the placeholders and their real content
foreach ($this->rendered as $id => $content) {
$search[] = "[[replace:$id]]";
$replace[] = $content;
}
 
// The HTML that Commonmark generated
$content = $event->getOutput()->getContent();
 
// First render the output without code blocks.
$content = Blade::render($content);
 
// Then add the code blocks back in.
$content = Str::replace($search, $replace, $content);
 
// And replace the entire response with our new, Blade-processed output.
$event->replaceOutput(
new RenderedContent($event->getOutput()->getDocument(), $content)
);
}
 
protected function isCodeNode($node) ...
{
return $node instanceof FencedCode
|| $node instanceof IndentedCode
|| $node instanceof Code;
}
}

Enabling the extension

To enable the extension, we need to add it to the markdown.php file in the extensions array. While we're there, we set html_input to allow so the HTML tags don't get HTML-escaped.

return [
// other configuration...
 
'extensions' => [
// Add the extension
BladeParsingExtension::class,
],
 
// Leave HTML tags alone
'html_input' => HtmlFilter::ALLOW,
]

Parse only code blocks

The second option is to use a "magic" code block. This technique has a few benefits, but I'm not sure it's any better.

The "magic" code block looks like this in markdown:

```blade +parse
<x-tweet url='https://twitter.com/aarondfrancis/status/1705211030882684946'>
The best part about being 90% done with a project is that you're almost halfway finished!
</x-tweet>
```

It's a pretty normal-looking code block: three backticks followed by the language identifier blade. But right after the identifier is a magic annotation +parse.

This +parse annotation will tell us that this is not a code block to be highlighted but rather one that should be run.

The nice thing about this method is that you get full syntax highlighting in your editor while working within the code block. That's kind of nice.

The second benefit is having fine-grained control over what gets parsed and what doesn't. Only the code blocks you mark as +parse will be processed. I don't know if this is a good thing or an annoyance.

Either way, we press on.

Creating a rendering extension

Just like before, we start out by creating an extension. This time we'll call it CodeRendererExtension. Instead of listening for events, we just register a few renderers for code blocks.

class CodeRendererExtension implements ExtensionInterface, NodeRendererInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addRenderer(FencedCode::class, $this, 100);
$environment->addRenderer(IndentedCode::class, $this, 100);
}
 
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
// @TODO something???
}
}

We give our renderers a relatively high priority (100) so that they run before any syntax highlighters get a hold of them.

Inside the render function, we can access the code node and do whatever we like. In our case, we'll check for the magic word, and if we find it, we'll run the contents through Blade.

class CodeRendererExtension implements ExtensionInterface, NodeRendererInterface
{
public function register(EnvironmentBuilderInterface $environment): void ...
{
$environment->addRenderer(FencedCode::class, $this, 100);
$environment->addRenderer(IndentedCode::class, $this, 100);
}
 
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
/** @var FencedCode|IndentedCode $node */
$info = $node->getInfoWords();
 
// Look for our magic word
if (in_array('+parse', $info)) {
// Run the content through Blade
return Blade::render($node->getLiteral());
}
}
}

As you can see, this is a much simpler method on the backend for a less flexible authoring experience.

I prefer the first method, where Blade parses it all, save for the code blocks.

Enabling the extension

Enabling the extension is the same as before. We add it to the markdown.php config. This time, you can leave the html_input as whatever you want, as we're not relying on HTML tags in markdown. Instead, they are wrapped up in code blocks.

return [
// other configuration...
 
'extensions' => [
// Add the extension
CodeRendererExtension::class,
],
]

Security considerations

Running Blade on user-provided content is super dangerous, and you should never, ever do it. If you're running Blade over content that you author, then Godspeed.

As a YouTube video

Want to watch it as a YouTube video? Well you're in luck!

As a package

I'll make this into an installable package in the next few days after I get some feedback from people on this post.

Me

Thanks for reading! My name is Aaron and I'm a Developer Educator at PlanetScale — a MySQL platform.

If you ever have any questions or want to chat, I'm always on Twitter.

You can find me on YouTube on my personal channel or my work channel, or my behind the scenes channel.