def78d4754
- Vimeo regex now rejects URLs like vimeo.com/123abc that were silently truncated to ID 123 and produced broken iframes. Negative lookahead (?![A-Za-z0-9]) ensures the captured digits are not followed by alphanumerics. Two false-positive test cases added. - Spec corrected: HtmlInline nodes ARE filtered regardless of insertion path; the implementation uses a dedicated MediaEmbedNode + renderer to bypass the filter only for trusted programmatic embeds. Components list updated to include the two extra files. - Plan Task 6 regex updated for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1041 lines
31 KiB
Markdown
1041 lines
31 KiB
Markdown
# 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 = [
|
|
'~^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**
|
|
|
|
```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
|
|
{
|
|
if (!preg_match('~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~', $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**
|
|
|
|
```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)
|