Wire MediaEmbedExtension into Document::renderMarkdown

The extension registers a DocumentParsedEvent listener that walks the
AST, finds Image nodes whose URLs match media patterns (via
MediaUrlResolver), and replaces them with MediaEmbedNode instances
containing the appropriate <video>/<audio>/<iframe> markup.

A custom MediaEmbedNode + MediaEmbedNodeRenderer pair bypasses the
html_input filter (which would strip raw HTML when set to 'strip'),
allowing programmatically generated embed HTML to pass through safely
while user-authored raw HTML remains stripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yutaka Kurosaki
2026-05-09 11:03:09 +09:00
parent 6ee4dcfc21
commit f26b930b5f
6 changed files with 130 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Markdown;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
class MediaEmbedExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$listener = new MediaEmbedListener(new MediaUrlResolver());
$environment->addEventListener(DocumentParsedEvent::class, [$listener, 'handle']);
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Markdown;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
class MediaEmbedListener
{
public function __construct(private readonly MediaUrlResolver $resolver)
{
}
public function handle(DocumentParsedEvent $event): void
{
$imagesToReplace = [];
foreach ($event->getDocument()->iterator() as $node) {
if ($node instanceof Image) {
$imagesToReplace[] = $node;
}
}
foreach ($imagesToReplace as $image) {
$html = $this->resolver->resolve($image->getUrl());
if ($html !== null) {
$image->replaceWith(new MediaEmbedNode($html));
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Inline\AbstractStringContainer;
/**
* A custom inline node for programmatically generated media embeds.
*
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
* so its renderer bypasses the html_input filter entirely, allowing us to emit
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
*/
class MediaEmbedNode extends AbstractStringContainer
{
}
@@ -0,0 +1,28 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
/**
* Renders a MediaEmbedNode by emitting its literal content directly,
* without going through any html_input filtering.
*/
class MediaEmbedNodeRenderer implements NodeRendererInterface
{
/**
* @param MediaEmbedNode $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
MediaEmbedNode::assertInstanceOf($node);
return $node->getLiteral();
}
}
+1
View File
@@ -127,6 +127,7 @@ public static function renderMarkdown(string $markdown): string
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
return $converter->convert($markdown)->getContent();
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\Unit\Markdown;
use App\Models\Document;
use Tests\TestCase;
class MediaEmbedExtensionTest extends TestCase
{
public function test_normal_image_still_renders_as_img(): void
{
$html = Document::renderMarkdown('![alt](/photo.png)');
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('src="/photo.png"', $html);
}
public function test_video_url_renders_as_video_tag(): void
{
$html = Document::renderMarkdown('![](/demo.mp4)');
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('src="/demo.mp4"', $html);
$this->assertStringNotContainsString('<img', $html);
}
public function test_youtube_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://youtu.be/dQw4w9WgXcQ)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com', $html);
}
public function test_vimeo_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://vimeo.com/123456789)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com', $html);
}
}