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
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>
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:
- Remove all code blocks from the markdown document (more on this below)
- Render the markdown to HTML
- Render the HTML through Blade
- Render the code blocks to HTML, separately
- 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; }}
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.