Ryan Chandler

Embedding Blade inside of Markdown content

4 min read

If you haven't read my blog post "Revitalising my site for 2025", I'd recommend going back to read it since I cover some of the cool things I'm doing to make my website perfect for my own workflow.

One of the things that I added was the ability to embed Blade template code inside of a Markdown file, allowing me to build custom Blade components for blog posts and then add them into the Markdown directly.

I've had a few people ask me how I'm doing this so I thought I'd show you.

Markdown extensions

The league/commonmark package has an extension API that lets you register custom blocks, inline elements, and renderers. the syntax I chose for my Blade extension looks like this:

@blade
    <!-- Put my custom Blade code here. -->
@endblade

This type of extension is a Block extension, since the syntax spans multiple lines. The league/commonmark package uses an AST (abstract syntax tree) to represent the parsed Markdown code before rendering HTML, so we need to tell the package how to parse this custom Blade block.

Writing the parser

Blocks typically span multiple lines and have some sort of opening & closing syntax. In this scenario that's @blade and @endblade. Everything in between that is parsed a literal string and any Markdown is ignored.

The Markdown parser needs to know when a block should start being parsed. This is done by writing a class that implements the BlockStartParserInterface.

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

class BladeStartParser implements BlockStartParserInterface
{
    const REGEX = '/^@blade/';

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

        if (! $cursor->match(self::REGEX)) {
            return BlockStart::none();
        }

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

The sole responsibility of this class is to determine whether or not the block is present at the current position in the Markdown document. Most blocks will use a regular expression to do this, like the BladeStartParser is.

If that RegEx doesn't match, or if the cursor is indented (not at the start of the line), then there's zero-chance that the block can be found, so it returns early.

If the @blade construct is found, then the parser is told to start parsing the rest of the block using the BladeParser class.

The BladeParser class extends AbstractBlockContinueParser and is responsible for parsing the text until the end of the block is reached.

use App\CommonMark\Block\Blade;
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
use League\CommonMark\Parser\Block\BlockContinue;
use League\CommonMark\Util\ArrayCollection;

class BladeParser extends AbstractBlockContinueParser
{
    private Blade $block;

    private ArrayCollection $strings;

    public function __construct()
    {
        $this->block = new Blade();
        $this->strings = new ArrayCollection();
    }

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

    public function addLine(string $line): void
    {
        $this->strings[] = $line;
    }

    public function closeBlock(): void
    {
        $this->block->setContent(
            ltrim(implode("\n", $this->strings->toArray()))
        );
    }

    public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
    {
        if ($cursor->match('/^@endblade/')) {
            return BlockContinue::finished();
        }

        return BlockContinue::at($cursor);
    }
}

There are a few more things going on inside of this class.

The Markdown parser needs to know which block is being parsed, so a custom Blade block is being stored in a property. There's also another property that stores a list of strings.

AbstractBlockContinueParser implements a method called isContainer() which tells the parser whether or not the block can contain other Markdown elements (headings, blockquotes, etc). The default implementation returns false which is fine in this case because Blade blocks aren't interpreted as Markdown.

Instead of parsing the content of the block as Markdown, the parser will consume each line inside of the block and call the addLine() method with a string containing the lines text. It needs to be collected and stored somewhere so that it can be passed to the Blade compiler later on.

tryContinue() is continuously checking to see if the @endblade marker can be found at the start of a line, indicating whether or not the block is finished.

If it can be found then the parser is told this block is no longer being parsed. Otherwise the parser continues consuming lines.

Once the parser is done parsing the Blade block, the closeBlock() method is called and any lines of text collected are stored inside of the custom Blade block object.

namespace App\CommonMark\Block;

use League\CommonMark\Node\Block\AbstractBlock;

final class Blade extends AbstractBlock
{
    public function __construct(
        private string $content = ''
    ) {}

    public function getContent(): string
    {
        return $this->content;
    }

    public function setContent(string $content): void
    {
        $this->content = $content;
    }
}

Block classes are mostly plain objects that store information about a block. They're not responsible for rendering.

Rendering the Blade template

To actually render a block, we need to write a custom block renderer.

use App\CommonMark\Block\Blade;
use Illuminate\Support\Facades\Blade as Engine;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;

class BladeRenderer implements NodeRendererInterface
{
    /**
     * @param Blade $node
     */
    public function render(Node $node, ChildNodeRendererInterface $childRenderer)
    {
        Blade::assertInstanceOf($node);

        return Engine::render($node->getContent());
    }
}

This class takes in a Node, in this case the Blade block from before, and returns a string of HTML that represents that block.

The Blade engine provides an API for compiling and rendering a string of Blade through the Blade::render() method (aliased to Engine to avoid collision with the block).

This is how the code between @blade and @endblade is compiled and eventually added to the final HTML output.

Hooking it all up

To actually tell the league/commonmark engine that there's a custom block, we need to register all of the custom classes with the engine.

In my blog, I do this by manually building an Environment object with all of the CommonMark features I need and registering a custom Extension that has my custom blocks and renderers.

$this->app->singleton(MarkdownConverter::class, static function (): MarkdownConverter {
    $environment = new Environment();

    $environment
        ->addExtension(new CommonMarkCoreExtension)
        ->addExtension(new GithubFlavoredMarkdownExtension)
        ->addExtension(new DescriptionListExtension)
        ->addExtension(new PhikiExtension(Theme::GithubDark))
        ->addExtension(new MyExtension);

    return new MarkdownConverter($environment);
});
use App\CommonMark\Block\Blade;
use App\CommonMark\Block\Parser\BladeStartParser;
use App\CommonMark\Block\Renderer\BladeRenderer;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;

final class MyExtension implements ExtensionInterface
{
    public function register(EnvironmentBuilderInterface $environment): void
    {
        $environment
            ->addBlockStartParser(new BladeStartParser, 80)
            ->addRenderer(Blade::class, new BladeRenderer);
    }
}

This tells the engine how to parse the @blade blocks and how to render the custom Blade block node.

Using in your own apps

Implementing this manually in your own applications is probably a bit too tedious, so I've decided to pull this out into a separate package. I'm already using it on my website!

Install the package with Composer:

composer require ryangjchandler/commonmark-blade-block

Then follow the instructions on the GitHub repository to get it setup.

Sign off

Hopefully this has been insightful. The league/commonmark package is excellent and incredibly extensible – you can basically do anything you want inside of a Markdown file.

Thanks for reading, catch you next time.