Essay

Tutorial · July 20, 2021

Fixing images on Laravel Vapor with League\Commonmark

Fixing broken markdown images on your Laravel Vapor site is easier than you think with this custom Commonmark extension that automatically routes asset URLs to the correct cloud location.

If you are using The PHP League's terrific Commonmark markdown parsing library and hosting your site on Laravel Vapor, then you may be running into an issue where your images are not showing up.

Vapor's documentation states that you must use the asset helper to generate the correct URLs for your images:

Because all of your assets will be served via S3 / CloudFront, you should always generate URLs to these assets using Laravel's asset helper. Vapor injects an ASSET_URL environment variable which Laravel's asset helper will use when constructing your URLs.

When you're working in markdown it's not quite that easy though, because you don't have access to the asset helper in markdown.

The solution is to write a Commonmark extension to do modify the URL on the fly, as the document is being parsed.

If you aren't familiar with Commonmark extensions, they are a convenient way for you to add functionality to the markdown parser. There are a number of first party ones, as well as many, many community authored ones.

We're going to write one here to look for image nodes and wrap the URL in the asset helper.

Writing the Extension

The first step is to create the extension class. We'll call it VaporAssetWrapping and implement the ExtensionInterface.

VaporAssetWrapping.php

<?php
namespace App;

use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Extension\ExtensionInterface;

class VaporAssetWrapping implements ExtensionInterface
{
    public function register(ConfigurableEnvironmentInterface $environment)
    {
        // @TODO
    }
}

There are a number of things you can do with an extension, we are specifically going to listen for the DocumentParsedEvent and look for image nodes.

The register method is the entry point, where we're going to add our listener. We'll also set up an empty method that will contain all of our logic.

<?php
namespace App;

use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;

class VaporAssetWrapping implements ExtensionInterface
{
    public function register(ConfigurableEnvironmentInterface $environment)
    {
        $environment->addEventListener(DocumentParsedEvent::class, [$this, 'onDocumentParsed']); 
    }

    public function onDocumentParsed(DocumentParsedEvent $event)
    {
        // @TODO
    }

}

Once the document is parsed we're going to walk through all the nodes and look for images. We can do this by comparing the current node to the Image class that Commonmark provides.

As we're walking the document we going to do a few things:

  • See if the node is an image. If not, skip it.
  • Only stop as we're entering the node.
  • See if the image source is external by checking to see if it starts with http. Skip if so.
  • Wrap the relative url in the asset helper and update the node.
<?php
namespace App;

use Illuminate\Support\Str;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Inline\Element\Image;

class VaporAssetWrapping implements ExtensionInterface
{
    public function register(ConfigurableEnvironmentInterface $environment)
    {
        $environment->addEventListener(DocumentParsedEvent::class, [$this, 'onDocumentParsed']);
    }

    public function onDocumentParsed(DocumentParsedEvent $event)
    {
        $walker = $event->getDocument()->walker(); 

        while ($event = $walker->next()) {
            $node = $event->getNode();

            // Only look for image nodes, and only process them upon entering.
            if (!$node instanceof Image || !$event->isEntering()) {
                continue;
            }

            // If it's an absolute URL, skip it.
            if (Str::startsWith($node->getUrl(), 'http')) {
                continue;
            }

            // Pass it through to the `asset` helper.
            $node->setUrl(asset($node->getUrl()));
        } 
    }
}

Registering the Extension

Now that you have your extension all written, you need to register it with the Commonmark parser.

If you are using Graham Campbell's markdown package then you just need to add it to your extensions key in the markdown.php file.

config/markdown.php

return [
    'extensions' => [
        // The location of our static assets varies based on wherever
        // Vapor puts them. This will point them to the right spot.
        VaporAssetWrapping::class,
    ],
];

If you aren't, then you'll need to manually register it:

$environment = Environment::createCommonMarkEnvironment();
$environment->addExtension(new VaporAssetWrapping);

That's all you need to do! Let me know on twitter if it worked for you!

Details
Published
July 20, 2021
Reading
3 min
Words
613
Newsletter

Every week: the best things I've found and what I've been thinking about.

 

Related
  1. Apr 2025A cookieless, cache-friendly image proxy in Laravel (inspired by Cloudflare)
  2. Nov 2024Reservable models in Laravel
  3. Nov 2024Make 5x faster outbound requests in Laravel
Colophon

Set in Tiempos and IBM Plex Mono on a 22px baseline.

Thanks for spending a little of your day here.