Files
knowledge_base/docs/superpowers/plans/2026-05-09-media-embed.md
T
Yutaka Kurosaki 6debaf93bc Fix regex delimiter in plan Task 5
Task 4 implementer discovered that # delimiter conflicts with literal #
inside [/?#] and [&#] character classes (PHP PCRE terminates the regex
early). Same patterns repeat in Task 5; pre-update so a re-execution
does not hit the same bug. Vimeo regex in Task 6 is unaffected (no
literal # in pattern body).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:51:48 +09:00

31 KiB

Markdown Media Embed Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extend the Markdown image syntax ![](url) so that URLs pointing to local video/audio files, YouTube, or Vimeo render as <video>, <audio>, or <iframe> instead of <img>, while preserving the existing html_input => 'strip' safety policy.

Architecture: Implement a CommonMark ExtensionInterface that registers a DocumentParsedEvent listener. The listener walks the AST, identifies Image nodes whose URLs match media patterns, and replaces them with HtmlInline nodes containing safe, escaped HTML. URL classification is delegated to a pure MediaUrlResolver class.

Tech Stack: PHP 8.2 / Laravel 12 / league/commonmark ^2.8 / PHPUnit (Laravel default).

Reference spec: docs/superpowers/specs/2026-05-09-media-embed-design.md


File Structure

File Status Responsibility
src/app/Markdown/MediaUrlResolver.php Create Pure URL classifier. Returns escaped HTML string or null.
src/app/Markdown/MediaEmbedListener.php Create Glue layer. Walks AST, replaces matching Image nodes with HtmlInline.
src/app/Markdown/MediaEmbedExtension.php Create CommonMark ExtensionInterface. Registers the listener.
src/app/Models/Document.php Modify Add one line to renderMarkdown() to register the extension.
src/tests/Unit/Markdown/MediaUrlResolverTest.php Create Exhaustive URL pattern tests against the resolver.
src/tests/Unit/Markdown/MediaEmbedExtensionTest.php Create Integration tests through Document::renderMarkdown().

All commands assume the project root /Library/WebServer/Documents/knowledge-base. Tests run inside the php container via docker compose exec php.


Task 1: Scaffold MediaUrlResolver with fallback behavior

Files:

  • Create: src/app/Markdown/MediaUrlResolver.php

  • Test: src/tests/Unit/Markdown/MediaUrlResolverTest.php

  • Step 1: Write the failing test

Create src/tests/Unit/Markdown/MediaUrlResolverTest.php:

<?php

namespace Tests\Unit\Markdown;

use App\Markdown\MediaUrlResolver;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

class MediaUrlResolverTest extends TestCase
{
    private MediaUrlResolver $resolver;

    protected function setUp(): void
    {
        parent::setUp();
        $this->resolver = new MediaUrlResolver();
    }

    #[DataProvider('nonMediaUrls')]
    public function test_returns_null_for_non_media_urls(string $url): void
    {
        $this->assertNull($this->resolver->resolve($url));
    }

    public static function nonMediaUrls(): array
    {
        return [
            'normal image' => ['/photo.jpg'],
            'svg' => ['/icon.svg'],
            'png' => ['/avatar.png'],
            'no extension' => ['/foo'],
            'empty string' => [''],
            'javascript scheme' => ['javascript:alert(1)'],
            'host-only' => ['http://'],
            'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
        ];
    }
}
  • Step 2: Run test to verify it fails
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: FAIL — Class "App\Markdown\MediaUrlResolver" not found.

  • Step 3: Create the resolver skeleton

Create src/app/Markdown/MediaUrlResolver.php:

<?php

namespace App\Markdown;

class MediaUrlResolver
{
    public function resolve(string $url): ?string
    {
        if ($url === '') {
            return null;
        }
        return null;
    }
}
  • Step 4: Run test to verify it passes
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS — all nonMediaUrls cases return null.

  • Step 5: Commit
git add src/app/Markdown/MediaUrlResolver.php src/tests/Unit/Markdown/MediaUrlResolverTest.php
git commit -m "$(cat <<'EOF'
Scaffold MediaUrlResolver with null fallback

Initial skeleton returning null for any non-media URL. Subsequent commits
add detection for video, audio, YouTube, and Vimeo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: Detect local video files

Files:

  • Modify: src/app/Markdown/MediaUrlResolver.php

  • Modify: src/tests/Unit/Markdown/MediaUrlResolverTest.php

  • Step 1: Add failing tests for video detection

Append to MediaUrlResolverTest.php (inside the class):

    #[DataProvider('videoUrls')]
    public function test_video_urls_produce_video_tag(string $url): void
    {
        $html = $this->resolver->resolve($url);
        $this->assertNotNull($html);
        $this->assertStringStartsWith('<video', $html);
        $this->assertStringContainsString('controls', $html);
        $this->assertStringContainsString('class="kb-video"', $html);
    }

    public static function videoUrls(): array
    {
        return [
            'mp4' => ['/demo.mp4'],
            'webm' => ['/demo.webm'],
            'ogv' => ['/demo.ogv'],
            'mov' => ['/demo.mov'],
            'm4v' => ['/demo.m4v'],
            'uppercase extension' => ['/demo.MP4'],
            'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
            'absolute http' => ['https://example.com/demo.mp4'],
        ];
    }

    public function test_video_url_is_html_escaped(): void
    {
        $html = $this->resolver->resolve('/path/with"quote.mp4');
        $this->assertNotNull($html);
        $this->assertStringNotContainsString('"quote.mp4"', $html);
        $this->assertStringContainsString('&quot;', $html);
    }
  • Step 2: Run tests to verify failure
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: FAIL — videoUrls cases assert $html not null but resolve() still returns null.

  • Step 3: Implement video detection

Replace the body of MediaUrlResolver.php with:

<?php

namespace App\Markdown;

class MediaUrlResolver
{
    private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];

    public function resolve(string $url): ?string
    {
        if ($url === '') {
            return null;
        }
        return $this->detectVideo($url);
    }

    private function detectVideo(string $url): ?string
    {
        if (!in_array($this->getPathExtension($url), self::VIDEO_EXT, true)) {
            return null;
        }
        $safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
        return "<video src=\"{$safe}\" controls class=\"kb-video\"></video>";
    }

    private function getPathExtension(string $url): string
    {
        $path = parse_url($url, PHP_URL_PATH);
        if ($path === null || $path === false) {
            return '';
        }
        return strtolower(pathinfo($path, PATHINFO_EXTENSION));
    }
}
  • Step 4: Run tests to verify pass
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS — all video cases plus existing fallback cases.

  • Step 5: Commit
git add src/app/Markdown/MediaUrlResolver.php src/tests/Unit/Markdown/MediaUrlResolverTest.php
git commit -m "$(cat <<'EOF'
Detect local video URLs in MediaUrlResolver

Recognizes mp4/webm/ogv/mov/m4v on URL path (case-insensitive, ignoring
query strings) and emits <video controls class="kb-video">.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Detect local audio files

Files:

  • Modify: src/app/Markdown/MediaUrlResolver.php

  • Modify: src/tests/Unit/Markdown/MediaUrlResolverTest.php

  • Step 1: Add failing tests for audio detection

Append to MediaUrlResolverTest.php:

    #[DataProvider('audioUrls')]
    public function test_audio_urls_produce_audio_tag(string $url): void
    {
        $html = $this->resolver->resolve($url);
        $this->assertNotNull($html);
        $this->assertStringStartsWith('<audio', $html);
        $this->assertStringContainsString('controls', $html);
        $this->assertStringContainsString('class="kb-audio"', $html);
    }

    public static function audioUrls(): array
    {
        return [
            'mp3' => ['/clip.mp3'],
            'wav' => ['/clip.wav'],
            'ogg' => ['/clip.ogg'],
            'm4a' => ['/clip.m4a'],
        ];
    }
  • Step 2: Run tests to verify failure
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: FAIL — audio URLs return null.

  • Step 3: Implement audio detection

In MediaUrlResolver.php, add the audio constant and method, and update resolve():

    private const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a'];

    public function resolve(string $url): ?string
    {
        if ($url === '') {
            return null;
        }
        return $this->detectVideo($url)
            ?? $this->detectAudio($url);
    }

    private function detectAudio(string $url): ?string
    {
        if (!in_array($this->getPathExtension($url), self::AUDIO_EXT, true)) {
            return null;
        }
        $safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
        return "<audio src=\"{$safe}\" controls class=\"kb-audio\"></audio>";
    }
  • Step 4: Run tests to verify pass
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS.

  • Step 5: Commit
git add src/app/Markdown/MediaUrlResolver.php src/tests/Unit/Markdown/MediaUrlResolverTest.php
git commit -m "$(cat <<'EOF'
Detect local audio URLs in MediaUrlResolver

Recognizes mp3/wav/ogg/m4a and emits <audio controls class="kb-audio">.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: Detect YouTube URLs (no timestamps yet)

Files:

  • Modify: src/app/Markdown/MediaUrlResolver.php

  • Modify: src/tests/Unit/Markdown/MediaUrlResolverTest.php

  • Step 1: Add failing tests for YouTube URLs

Append to MediaUrlResolverTest.php:

    #[DataProvider('youtubeUrls')]
    public function test_youtube_urls_produce_iframe(string $url): void
    {
        $html = $this->resolver->resolve($url);
        $this->assertNotNull($html);
        $this->assertStringStartsWith('<iframe', $html);
        $this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
        $this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
        $this->assertStringContainsString('loading="lazy"', $html);
        $this->assertStringContainsString('allowfullscreen', $html);
    }

    public static function youtubeUrls(): array
    {
        return [
            'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
            'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
            'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
            'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
            'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
            'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
        ];
    }

    #[DataProvider('invalidYoutubeUrls')]
    public function test_invalid_youtube_urls_return_null(string $url): void
    {
        $this->assertNull($this->resolver->resolve($url));
    }

    public static function invalidYoutubeUrls(): array
    {
        return [
            'too short id' => ['https://youtu.be/short'],
            'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
            'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
        ];
    }
  • Step 2: Run tests to verify failure
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: FAIL — YouTube URLs return null.

  • Step 3: Implement YouTube detection (no timestamps)

In MediaUrlResolver.php, update resolve() and add YouTube methods:

    public function resolve(string $url): ?string
    {
        if ($url === '') {
            return null;
        }
        return $this->detectVideo($url)
            ?? $this->detectAudio($url)
            ?? $this->detectYouTube($url);
    }

    private function detectYouTube(string $url): ?string
    {
        $patterns = [
            '#^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)#',
            '#^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)#',
            '#^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)#',
            '#^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)#',
        ];
        $videoId = null;
        foreach ($patterns as $p) {
            if (preg_match($p, $url, $m)) {
                $videoId = $m[1];
                break;
            }
        }
        if ($videoId === null) {
            return null;
        }
        $src = "https://www.youtube-nocookie.com/embed/{$videoId}";
        return $this->iframeHtml($src, 'youtube');
    }

    private function iframeHtml(string $src, string $provider): string
    {
        $safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
        return '<iframe src="' . $safe . '" '
            . 'width="560" height="315" '
            . 'loading="lazy" '
            . 'referrerpolicy="strict-origin-when-cross-origin" '
            . 'allow="autoplay; encrypted-media; picture-in-picture" '
            . 'allowfullscreen frameborder="0" '
            . 'class="kb-embed kb-embed-' . $provider . '"></iframe>';
    }
  • Step 4: Run tests to verify pass
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS.

  • Step 5: Commit
git add src/app/Markdown/MediaUrlResolver.php src/tests/Unit/Markdown/MediaUrlResolverTest.php
git commit -m "$(cat <<'EOF'
Detect YouTube URLs and emit privacy-enhanced iframe

Recognizes youtu.be, watch?v=, shorts, embed, and mobile variants.
Emits an iframe pointing to youtube-nocookie.com with lazy loading,
strict-origin referrer policy, and allowfullscreen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: YouTube timestamp normalization

Files:

  • Modify: src/app/Markdown/MediaUrlResolver.php

  • Modify: src/tests/Unit/Markdown/MediaUrlResolverTest.php

  • Step 1: Add failing tests for YouTube timestamps

Append to MediaUrlResolverTest.php:

    #[DataProvider('youtubeTimestampUrls')]
    public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
    {
        $html = $this->resolver->resolve($url);
        $this->assertNotNull($html);
        $this->assertStringContainsString("?start={$expectedStart}", $html);
    }

    public static function youtubeTimestampUrls(): array
    {
        return [
            't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
            't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
            't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
            't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
            'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
        ];
    }
  • Step 2: Run tests to verify failure
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: FAIL — ?start=N not present in iframe src.

  • Step 3: Implement timestamp extraction

In MediaUrlResolver.php, modify detectYouTube() and add helper methods:

    private function detectYouTube(string $url): ?string
    {
        $patterns = [
            '~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
            '~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~',
            '~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
            '~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
        ];
        $videoId = null;
        foreach ($patterns as $p) {
            if (preg_match($p, $url, $m)) {
                $videoId = $m[1];
                break;
            }
        }
        if ($videoId === null) {
            return null;
        }
        $src = "https://www.youtube-nocookie.com/embed/{$videoId}";
        $start = $this->extractYouTubeStart($url);
        if ($start !== null) {
            $src .= "?start={$start}";
        }
        return $this->iframeHtml($src, 'youtube');
    }

    private function extractYouTubeStart(string $url): ?int
    {
        if (preg_match('/[?&]t=([^&#]+)/', $url, $m)) {
            $seconds = $this->parseTimestamp($m[1]);
            if ($seconds !== null) {
                return $seconds;
            }
        }
        if (preg_match('/[?&]start=(\d+)/', $url, $m)) {
            return (int) $m[1];
        }
        return null;
    }

    private function parseTimestamp(string $t): ?int
    {
        if (ctype_digit($t)) {
            return (int) $t;
        }
        $total = 0;
        $matched = false;
        if (preg_match('/(\d+)h/', $t, $m)) {
            $total += (int) $m[1] * 3600;
            $matched = true;
        }
        if (preg_match('/(\d+)m/', $t, $m)) {
            $total += (int) $m[1] * 60;
            $matched = true;
        }
        if (preg_match('/(\d+)s/', $t, $m)) {
            $total += (int) $m[1];
            $matched = true;
        }
        return $matched ? $total : null;
    }
  • Step 4: Run tests to verify pass
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS.

  • Step 5: Commit
git add src/app/Markdown/MediaUrlResolver.php src/tests/Unit/Markdown/MediaUrlResolverTest.php
git commit -m "$(cat <<'EOF'
Normalize YouTube timestamp parameters to ?start=N

Accepts ?t=30s, ?t=30, ?t=1m20s, ?t=1h2m3s, and ?start=N. Converts to
seconds and emits as ?start=N on the embed URL. ?t= takes priority over
?start= when both are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: Detect Vimeo URLs

Files:

  • Modify: src/app/Markdown/MediaUrlResolver.php

  • Modify: src/tests/Unit/Markdown/MediaUrlResolverTest.php

  • Step 1: Add failing tests for Vimeo URLs

Append to MediaUrlResolverTest.php:

    #[DataProvider('vimeoUrls')]
    public function test_vimeo_urls_produce_iframe(string $url): void
    {
        $html = $this->resolver->resolve($url);
        $this->assertNotNull($html);
        $this->assertStringStartsWith('<iframe', $html);
        $this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
        $this->assertStringContainsString('dnt=1', $html);
        $this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
    }

    public static function vimeoUrls(): array
    {
        return [
            'vimeo.com' => ['https://vimeo.com/123456789'],
            'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
            'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
        ];
    }

    #[DataProvider('vimeoTimestampUrls')]
    public function test_vimeo_timestamp_preserved_as_hash(string $url): void
    {
        $html = $this->resolver->resolve($url);
        $this->assertNotNull($html);
        $this->assertStringContainsString('#t=30s', $html);
    }

    public static function vimeoTimestampUrls(): array
    {
        return [
            'hash form' => ['https://vimeo.com/123456789#t=30s'],
            'query form' => ['https://vimeo.com/123456789?t=30s'],
        ];
    }

    public function test_vimeo_invalid_id_returns_null(): void
    {
        $this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
    }
  • Step 2: Run tests to verify failure
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: FAIL — Vimeo URLs return null.

  • Step 3: Implement Vimeo detection

In MediaUrlResolver.php, update resolve() and add Vimeo methods:

    public function resolve(string $url): ?string
    {
        if ($url === '') {
            return null;
        }
        return $this->detectVideo($url)
            ?? $this->detectAudio($url)
            ?? $this->detectYouTube($url)
            ?? $this->detectVimeo($url);
    }

    private function detectVimeo(string $url): ?string
    {
        if (!preg_match('#^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)#', $url, $m)) {
            return null;
        }
        $videoId = $m[1];
        $src = "https://player.vimeo.com/video/{$videoId}?dnt=1";
        $hash = $this->extractVimeoHash($url);
        if ($hash !== null) {
            $src .= '#' . $hash;
        }
        return $this->iframeHtml($src, 'vimeo');
    }

    private function extractVimeoHash(string $url): ?string
    {
        if (preg_match('/#(t=[^&]+)/', $url, $m)) {
            return $m[1];
        }
        if (preg_match('/[?&](t=[^&#]+)/', $url, $m)) {
            return $m[1];
        }
        return null;
    }
  • Step 4: Run tests to verify pass
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS.

  • Step 5: Commit
git add src/app/Markdown/MediaUrlResolver.php src/tests/Unit/Markdown/MediaUrlResolverTest.php
git commit -m "$(cat <<'EOF'
Detect Vimeo URLs and emit iframe with dnt=1

Recognizes vimeo.com/{id} and player.vimeo.com/video/{id}. Preserves
timestamps from #t=30s and ?t=30s as #t=30s on the embed URL (Vimeo
convention).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: Wire up extension and listener; integrate with Document::renderMarkdown()

Files:

  • Create: src/app/Markdown/MediaEmbedListener.php

  • Create: src/app/Markdown/MediaEmbedExtension.php

  • Create: src/tests/Unit/Markdown/MediaEmbedExtensionTest.php

  • Modify: src/app/Models/Document.php

  • Step 1: Write the failing integration test

Create src/tests/Unit/Markdown/MediaEmbedExtensionTest.php:

<?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);
    }
}
  • Step 2: Run tests to verify failure
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest

Expected: FAIL — ![](/demo.mp4) still renders as <img> because the extension is not registered.

  • Step 3: Create the listener

Create src/app/Markdown/MediaEmbedListener.php:

<?php

namespace App\Markdown;

use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Node\Inline\HtmlInline;

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 HtmlInline($html));
            }
        }
    }
}
  • Step 4: Create the extension

Create src/app/Markdown/MediaEmbedExtension.php:

<?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']);
    }
}
  • Step 5: Register the extension in Document::renderMarkdown

Edit src/app/Models/Document.php. Locate renderMarkdown() (around line 122) and add the extension registration:

    public static function renderMarkdown(string $markdown): string
    {
        $converter = new CommonMarkConverter([
            'html_input' => 'strip',
            'allow_unsafe_links' => false,
        ]);

        $converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
        $converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());

        return $converter->convert($markdown)->getContent();
    }
  • Step 6: Run tests to verify pass
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest

Expected: PASS — all four cases.

  • Step 7: Run the resolver tests to confirm no regression
docker compose exec php php artisan test --filter=MediaUrlResolverTest

Expected: PASS.

  • Step 8: Commit
git add src/app/Markdown/MediaEmbedListener.php src/app/Markdown/MediaEmbedExtension.php src/app/Models/Document.php src/tests/Unit/Markdown/MediaEmbedExtensionTest.php
git commit -m "$(cat <<'EOF'
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 HtmlInline nodes containing
the appropriate <video>/<audio>/<iframe> markup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: Integration tests for mixed content and edge cases

Files:

  • Modify: src/tests/Unit/Markdown/MediaEmbedExtensionTest.php

  • Step 1: Add tests for mixed content scenarios

Append to MediaEmbedExtensionTest.php:

    public function test_image_and_video_coexist_in_same_document(): void
    {
        $md = "![photo](/photo.png)\n\n![](/demo.mp4)";
        $html = Document::renderMarkdown($md);
        $this->assertStringContainsString('<img', $html);
        $this->assertStringContainsString('<video', $html);
    }

    public function test_multiple_media_in_same_paragraph(): void
    {
        $html = Document::renderMarkdown('![](/a.mp4) and ![](/b.mp4)');
        $this->assertSame(2, substr_count($html, '<video'));
    }

    public function test_video_inside_list_item(): void
    {
        $html = Document::renderMarkdown("- ![](/demo.mp4)");
        $this->assertStringContainsString('<li>', $html);
        $this->assertStringContainsString('<video', $html);
    }

    public function test_wiki_link_unaffected_alongside_media(): void
    {
        $html = Document::renderMarkdown("![](/demo.mp4)\n\n[[Other Doc]]");
        $this->assertStringContainsString('<video', $html);
        $this->assertStringContainsString('[[Other Doc]]', $html);
    }

    public function test_youtube_with_timestamp_in_document(): void
    {
        $html = Document::renderMarkdown('![](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s)');
        $this->assertStringContainsString('?start=30', $html);
    }

    public function test_audio_url_renders_as_audio_tag(): void
    {
        $html = Document::renderMarkdown('![](/clip.mp3)');
        $this->assertStringContainsString('<audio', $html);
        $this->assertStringContainsString('src="/clip.mp3"', $html);
    }

Note on the wiki link test: Document::renderMarkdown produces raw HTML; [[Other Doc]] is converted to anchors only by Document::processLinks(), which is called separately. The test asserts that the raw [[...]] text passes through renderMarkdown unmodified — that is the contract being verified.

  • Step 2: Run tests
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest

Expected: PASS — all six new cases plus the four from Task 7.

  • Step 3: Commit
git add src/tests/Unit/Markdown/MediaEmbedExtensionTest.php
git commit -m "$(cat <<'EOF'
Add integration tests for mixed media in Markdown rendering

Covers image+video coexistence, multiple videos in one paragraph,
videos inside list items, wiki link non-interference, YouTube
timestamps end-to-end, and audio rendering through renderMarkdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 9: Full test suite verification

Files:

  • None (verification only)

  • Step 1: Run full test suite

docker compose exec php composer test

Expected: All tests pass. The new tests are additive and should not affect existing Profile/Auth/etc. tests.

  • Step 2: If anything failed, investigate and fix

If a previously-passing test now fails, the most likely cause is a regression in Document::renderMarkdown() from the extension change. Investigate with:

docker compose exec php php artisan test --filter=<failing-test-name> -v

Common potential issues:

  • An existing seeded Document containing Markdown that newly matches a media pattern (e.g. ![](/foo.mov)) renders differently. This is the intended new behavior; update the test fixture if it asserts the old <img> form.

  • Namespace import collision in Document.php. Ensure use App\Markdown\MediaEmbedExtension; is added at the top of the file (or use the fully-qualified name as shown in Task 7).

  • Step 3: If clean, no commit needed

If all tests pass, no further commit is required. The work is complete.

If a fix was needed, commit it:

git add <changed-files>
git commit -m "Fix regression introduced by MediaEmbedExtension"

Out of Scope (future tasks, not part of this plan)

  • docs:rerender Artisan command to re-render existing Document::rendered_html rows after deployment
  • CSS rules for .kb-video, .kb-audio, .kb-embed-* (likely added when designing public-facing layout)
  • Updating CLAUDE.md to describe the new media-embed convention
  • VTT subtitles / <track> elements
  • Additional providers (Twitch, SoundCloud, Spotify)