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:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ public static function renderMarkdown(string $markdown): string
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
||||||
|
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
|
||||||
|
|
||||||
return $converter->convert($markdown)->getContent();
|
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('');
|
||||||
|
$this->assertStringContainsString('<img', $html);
|
||||||
|
$this->assertStringContainsString('src="/photo.png"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_video_url_renders_as_video_tag(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$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('');
|
||||||
|
$this->assertStringContainsString('<iframe', $html);
|
||||||
|
$this->assertStringContainsString('youtube-nocookie.com', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_vimeo_url_renders_as_iframe(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('<iframe', $html);
|
||||||
|
$this->assertStringContainsString('player.vimeo.com', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user