2026-05-09 10:25:08 +09:00
# 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 `` 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
< ? 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 **
``` bash
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
< ? 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 **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS — all `nonMediaUrls` cases return null.
- [ ] **Step 5: Commit **
``` bash
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):
``` php
#[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 ( '"' , $html );
}
```
- [ ] **Step 2: Run tests to verify failure **
``` bash
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
< ? 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 **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS — all video cases plus existing fallback cases.
- [ ] **Step 5: Commit **
``` bash
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` :
``` 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 **
``` bash
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()` :
``` php
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 **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS.
- [ ] **Step 5: Commit **
``` bash
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` :
``` 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 **
``` bash
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:
``` php
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 **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS.
- [ ] **Step 5: Commit **
``` bash
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` :
``` 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 **
``` bash
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:
``` php
private function detectYouTube ( string $url ) : ? string
{
$patterns = [
2026-05-09 10:51:48 +09:00
'~^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})(?:[/?#]|$)~' ,
2026-05-09 10:25:08 +09:00
];
$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 **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS.
- [ ] **Step 5: Commit **
``` bash
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` :
``` 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 **
``` bash
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:
``` php
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
{
2026-05-09 11:18:26 +09:00
if ( ! preg_match ( '~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~' , $url , $m )) {
2026-05-09 10:25:08 +09:00
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 **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS.
- [ ] **Step 5: Commit **
``` bash
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
< ? 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 );
}
}
```
- [ ] **Step 2: Run tests to verify failure **
``` bash
docker compose exec php php artisan test --filter= MediaEmbedExtensionTest
```
Expected: FAIL — `` still renders as `<img>` because the extension is not registered.
- [ ] **Step 3: Create the listener **
Create `src/app/Markdown/MediaEmbedListener.php` :
``` 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
< ? 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:
``` php
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 **
``` bash
docker compose exec php php artisan test --filter= MediaEmbedExtensionTest
```
Expected: PASS — all four cases.
- [ ] **Step 7: Run the resolver tests to confirm no regression **
``` bash
docker compose exec php php artisan test --filter= MediaUrlResolverTest
```
Expected: PASS.
- [ ] **Step 8: Commit **
``` bash
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` :
``` php
public function test_image_and_video_coexist_in_same_document () : void
{
$md = "  \n \n  " ;
$html = Document :: renderMarkdown ( $md );
$this -> assertStringContainsString ( '<img' , $html );
$this -> assertStringContainsString ( '<video' , $html );
}
public function test_multiple_media_in_same_paragraph () : void
{
$html = Document :: renderMarkdown ( ' and ' );
$this -> assertSame ( 2 , substr_count ( $html , '<video' ));
}
public function test_video_inside_list_item () : void
{
$html = Document :: renderMarkdown ( " -  " );
$this -> assertStringContainsString ( '<li>' , $html );
$this -> assertStringContainsString ( '<video' , $html );
}
public function test_wiki_link_unaffected_alongside_media () : void
{
$html = Document :: renderMarkdown ( "  \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 ( '' );
$this -> assertStringContainsString ( '?start=30' , $html );
}
public function test_audio_url_renders_as_audio_tag () : void
{
$html = Document :: renderMarkdown ( '' );
$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 **
``` bash
docker compose exec php php artisan test --filter= MediaEmbedExtensionTest
```
Expected: PASS — all six new cases plus the four from Task 7.
- [ ] **Step 3: Commit **
``` bash
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 **
``` bash
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:
``` bash
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. `` ) 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:
``` bash
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)