From 6ee4dcfc21d2ff69d39e06f4a52692b9f648224f Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sat, 9 May 2026 10:57:05 +0900 Subject: [PATCH] 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) --- src/app/Markdown/MediaUrlResolver.php | 28 ++++++++++++- .../Unit/Markdown/MediaUrlResolverTest.php | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/app/Markdown/MediaUrlResolver.php b/src/app/Markdown/MediaUrlResolver.php index a277bbb..23d266e 100644 --- a/src/app/Markdown/MediaUrlResolver.php +++ b/src/app/Markdown/MediaUrlResolver.php @@ -15,7 +15,8 @@ public function resolve(string $url): ?string } return $this->detectVideo($url) ?? $this->detectAudio($url) - ?? $this->detectYouTube($url); + ?? $this->detectYouTube($url) + ?? $this->detectVimeo($url); } private function detectVideo(string $url): ?string @@ -98,6 +99,31 @@ private function parseTimestamp(string $t): ?int return $matched ? $total : null; } + 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; + } + private function iframeHtml(string $src, string $provider): string { $safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8'); diff --git a/src/tests/Unit/Markdown/MediaUrlResolverTest.php b/src/tests/Unit/Markdown/MediaUrlResolverTest.php index 6230342..2934fd7 100644 --- a/src/tests/Unit/Markdown/MediaUrlResolverTest.php +++ b/src/tests/Unit/Markdown/MediaUrlResolverTest.php @@ -145,4 +145,45 @@ public static function youtubeTimestampUrls(): array 'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45], ]; } + + #[DataProvider('vimeoUrls')] + public function test_vimeo_urls_produce_iframe(string $url): void + { + $html = $this->resolver->resolve($url); + $this->assertNotNull($html); + $this->assertStringStartsWith('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')); + } }