From aed6a733d26afd7ae383394c4ddf973a172adead Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 14 Sep 2026 15:39:31 -0700 Subject: [PATCH 01/12] Augment video fields to a value object carrying the provider --- src/Fieldtypes/Video.php | 10 ++ src/Fieldtypes/Video/Embed.php | 213 +++++++++++++++++++++++++++ src/Modifiers/CoreModifiers.php | 84 ++--------- tests/Fieldtypes/Video/EmbedTest.php | 83 +++++++++++ tests/Fieldtypes/VideoTest.php | 55 +++++++ tests/Modifiers/EmbedUrlTest.php | 15 ++ 6 files changed, 389 insertions(+), 71 deletions(-) create mode 100644 src/Fieldtypes/Video/Embed.php create mode 100644 tests/Fieldtypes/Video/EmbedTest.php create mode 100644 tests/Fieldtypes/VideoTest.php diff --git a/src/Fieldtypes/Video.php b/src/Fieldtypes/Video.php index 5e12dbf48bf..aea2ee6fb07 100644 --- a/src/Fieldtypes/Video.php +++ b/src/Fieldtypes/Video.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Statamic\Fields\Fieldtype; +use Statamic\Fieldtypes\Video\Embed; use function Statamic\trans as __; @@ -10,6 +11,15 @@ class Video extends Fieldtype { protected $categories = ['media']; + public function augment($value) + { + if (is_null($value)) { + return null; + } + + return Embed::fromValue($value); + } + protected function configFieldItems(): array { return [ diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php new file mode 100644 index 00000000000..b2b29ee9256 --- /dev/null +++ b/src/Fieldtypes/Video/Embed.php @@ -0,0 +1,213 @@ +provider !== self::UNSUPPORTED; + } + + public function toArray(): array + { + return [ + 'embed_url' => $this->embedUrl, + 'id' => $this->id, + 'provider' => $this->provider, + 'url' => $this->url, + ]; + } + + public function toBool(): bool + { + return $this->isSupported(); + } + + public function __toString(): string + { + return (string) $this->url; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } + + #[\ReturnTypeWillChange] + public function offsetExists(mixed $offset) + { + return array_key_exists($offset, $this->toArray()); + } + + #[\ReturnTypeWillChange] + public function offsetGet(mixed $offset) + { + return $this->toArray()[$offset] ?? null; + } + + #[\ReturnTypeWillChange] + public function offsetSet(mixed $offset, mixed $value) + { + } + + #[\ReturnTypeWillChange] + public function offsetUnset(mixed $offset) + { + } + + /** + * Turn a link that's direct to a video's page into its embeddable equivalent. + */ + public static function embedUrl(string $url): string + { + if (Str::contains($url, self::VIMEO)) { + return static::vimeoEmbedUrl($url); + } + + if (Str::contains($url, 'youtu.be')) { + $url = str_replace('youtu.be', 'www.youtube.com/embed', $url); + + // Check for start at point and replace it with correct parameter. + if (Str::contains($url, '?t=')) { + $url = str_replace('?t=', '?start=', $url); + } + } + + if (Str::contains($url, 'youtube.com/watch?v=')) { + $url = str_replace('watch?v=', 'embed/', $url); + + if (Str::contains($url, '&t=')) { + $url = str_replace('&t=', '?start=', $url); + } + } + + if (Str::contains($url, 'youtube.com/shorts/')) { + $url = str_replace('shorts/', 'embed/', $url); + } + + if (Str::contains($url, 'youtube.com')) { + $url = str_replace('youtube.com', 'youtube-nocookie.com', $url); + } + + // This avoids SSL issues when using the non-www version + if (Str::contains($url, '//youtube-nocookie.com')) { + $url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url); + } + + if (Str::contains($url, '&') && ! Str::contains($url, '?')) { + $url = Str::replaceFirst('&', '?', $url); + } + + return $url; + } + + public static function isEmbeddable(string $url): bool + { + return Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]); + } + + protected static function isVideoFile(string $url): bool + { + if (blank($path = parse_url($url, PHP_URL_PATH))) { + return false; + } + + return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), FileTypes::video()); + } + + protected static function oembedProvider(string $url): ?string + { + if (Str::contains($url, self::VIMEO)) { + return self::VIMEO; + } + + if (Str::contains($url, ['youtu.be', 'youtube'])) { + return self::YOUTUBE; + } + + return null; + } + + // Unlisted vimeo urls are in the form vimeo.com/id/hash, but embeds pass the hash as a get param. + protected static function vimeoEmbedUrl(string $url): string + { + $url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url); + $hash = ''; + + if (! Str::contains($url, 'progressive_redirect') && Str::substrCount($url, '/') > 4) { + $hash = Str::afterLast($url, '/'); + $url = Str::beforeLast($url, '/'); + + if (Str::contains($hash, '?')) { + $url .= '?'.Str::after($hash, '?'); + $hash = Str::before($hash, '?'); + } + } + + $paramsToAdd = '?dnt=1'; + + if ($hash) { + $paramsToAdd .= '&h='.$hash; + } + + return Str::contains($url, '?') + ? str_replace('?', $paramsToAdd.'&', $url) + : $url.$paramsToAdd; + } +} diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index 42e3d800704..7e9dd96acff 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -28,6 +28,7 @@ use Statamic\Fieldtypes\Bard; use Statamic\Fieldtypes\Bard\Augmentor; use Statamic\Fieldtypes\Link\ArrayableLink; +use Statamic\Fieldtypes\Video\Embed; use Statamic\Statamic; use Statamic\Support\Arr; use Statamic\Support\Dumper; @@ -3199,60 +3200,11 @@ public function yearsAgo($value, $params) */ public function embedUrl($url) { - if (Str::contains($url, 'vimeo')) { - $url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url); - - [$url, $hash] = $this->handleUnlistedVimeoUrls($url); - - $paramsToAdd = '?dnt=1'; - if ($hash) { - $paramsToAdd .= '&h='.$hash; - } - - if (Str::contains($url, '?')) { - $url = str_replace('?', $paramsToAdd.'&', $url); - } else { - $url .= $paramsToAdd; - } - - return $url; + if ($url instanceof Embed) { + return $url->embedUrl; } - if (Str::contains($url, 'youtu.be')) { - $url = str_replace('youtu.be', 'www.youtube.com/embed', $url); - - // Check for start at point and replace it with correct parameter. - if (Str::contains($url, '?t=')) { - $url = str_replace('?t=', '?start=', $url); - } - } - - if (Str::contains($url, 'youtube.com/watch?v=')) { - $url = str_replace('watch?v=', 'embed/', $url); - - if (Str::contains($url, '&t=')) { - $url = str_replace('&t=', '?start=', $url); - } - } - - if (Str::contains($url, 'youtube.com/shorts/')) { - $url = str_replace('shorts/', 'embed/', $url); - } - - if (Str::contains($url, 'youtube.com')) { - $url = str_replace('youtube.com', 'youtube-nocookie.com', $url); - } - - // This avoids SSL issues when using the non-www version - if (Str::contains($url, '//youtube-nocookie.com')) { - $url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url); - } - - if (Str::contains($url, '&') && ! Str::contains($url, '?')) { - $url = Str::replaceFirst('&', '?', $url); - } - - return $url; + return Embed::embedUrl($url); } /** @@ -3264,6 +3216,10 @@ public function embedUrl($url) */ public function trackableEmbedUrl($url) { + if ($url instanceof Embed) { + return $url->embedUrl; + } + if (Str::contains($url, 'vimeo')) { return str_replace('/vimeo.com', '/player.vimeo.com/video', $url); } @@ -3296,7 +3252,11 @@ public function trackableEmbedUrl($url) */ public function isEmbeddable($url) { - return Str::contains($url, ['youtu.be', 'youtube', 'vimeo']); + if ($url instanceof Embed) { + return $url->isSupported(); + } + + return Embed::isEmbeddable($url); } /** @@ -3381,24 +3341,6 @@ private function getFromContext($context, $params, $key = 0) Arr::get($context, $params[$key], $params[$key]); } - // unlisted vimeo urls are in the form vimeo.com/id/hash, but embeds pass the hash as a get param - private function handleUnlistedVimeoUrls($url) - { - $hash = ''; - - if (! Str::contains($url, 'progressive_redirect') && Str::substrCount($url, '/') > 4) { - $hash = Str::afterLast($url, '/'); - $url = Str::beforeLast($url, '/'); - - if (Str::contains($hash, '?')) { - $url .= '?'.Str::after($hash, '?'); - $hash = Str::before($hash, '?'); - } - } - - return [$url, $hash]; - } - private function dumpingAllowed(array $params): bool { return $this->traitDumpingAllowed() || (Arr::get($params, 0) === 'force'); diff --git a/tests/Fieldtypes/Video/EmbedTest.php b/tests/Fieldtypes/Video/EmbedTest.php new file mode 100644 index 00000000000..4d23a2c2700 --- /dev/null +++ b/tests/Fieldtypes/Video/EmbedTest.php @@ -0,0 +1,83 @@ +assertSame($provider, $video->provider); + $this->assertSame($id, $video->id); + $this->assertSame($embedUrl, $video->embedUrl); + } + + public static function valuesProvider() + { + return [ + 'youtube' => ['https://www.youtube.com/watch?v=FK3dav4bA4s', 'youtube', null, 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'], + 'youtube shorts' => ['https://www.youtube.com/shorts/FK3dav4bA4s', 'youtube', null, 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'], + 'youtu.be' => ['https://youtu.be/FK3dav4bA4s', 'youtube', null, 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'], + 'vimeo' => ['https://vimeo.com/22439234', 'vimeo', null, 'https://player.vimeo.com/video/22439234?dnt=1'], + 'cloudflare' => ['cloudflare:1234', 'cloudflare', '1234', 'https://iframe.cloudflarestream.com/1234'], + 'cloudflare without an id' => ['cloudflare:', 'unsupported', null, null], + 'cloudflare with a malformed id' => ['cloudflare:1234">', 'unsupported', null, null], + 'cloudflare with a path traversal id' => ['cloudflare:../../evil', 'unsupported', null, null], + 'mp4 file' => ['https://example.com/clip.mp4', 'file', null, 'https://example.com/clip.mp4'], + 'uppercase file extension' => ['https://example.com/clip.MOV', 'file', null, 'https://example.com/clip.MOV'], + 'file with a query string' => ['https://example.com/clip.webm?t=1', 'file', null, 'https://example.com/clip.webm?t=1'], + 'unsupported' => ['https://example.com/nope', 'unsupported', null, null], + 'empty' => ['', 'unsupported', null, null], + 'null' => [null, 'unsupported', null, null], + ]; + } + + #[Test] + public function it_casts_to_the_original_value() + { + $this->assertSame('https://vimeo.com/22439234', (string) Embed::fromValue('https://vimeo.com/22439234')); + $this->assertSame('cloudflare:1234', (string) Embed::fromValue('cloudflare:1234')); + $this->assertSame('', (string) Embed::fromValue(null)); + } + + #[Test] + public function it_is_falsey_when_unsupported() + { + $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->toBool()); + $this->assertFalse(Embed::fromValue('https://example.com/nope')->toBool()); + } + + #[Test] + public function it_is_arrayable_and_accessible_as_an_array() + { + $video = Embed::fromValue('cloudflare:1234'); + + $this->assertSame([ + 'embed_url' => 'https://iframe.cloudflarestream.com/1234', + 'id' => '1234', + 'provider' => 'cloudflare', + 'url' => 'cloudflare:1234', + ], $video->toArray()); + + $this->assertSame('https://iframe.cloudflarestream.com/1234', $video['embed_url']); + $this->assertTrue(isset($video['provider'])); + $this->assertNull($video['nope']); + } + + #[Test] + public function it_serializes_to_json_as_its_array() + { + $this->assertSame( + json_encode(Embed::fromValue('cloudflare:1234')->toArray()), + json_encode(Embed::fromValue('cloudflare:1234')), + ); + } +} diff --git a/tests/Fieldtypes/VideoTest.php b/tests/Fieldtypes/VideoTest.php new file mode 100644 index 00000000000..ee6646b298e --- /dev/null +++ b/tests/Fieldtypes/VideoTest.php @@ -0,0 +1,55 @@ +assertNull($this->fieldtype()->augment(null)); + } + + #[Test] + #[DataProvider('augmentProvider')] + public function it_augments_to_a_video($value, $provider, $id, $embedUrl) + { + $video = $this->fieldtype()->augment($value); + + $this->assertInstanceOf(Embed::class, $video); + $this->assertSame($provider, $video->provider); + $this->assertSame($id, $video->id); + $this->assertSame($embedUrl, $video->embedUrl); + } + + public static function augmentProvider() + { + return [ + 'url' => ['https://vimeo.com/22439234', 'vimeo', null, 'https://player.vimeo.com/video/22439234?dnt=1'], + 'cloudflare' => ['cloudflare:1234', 'cloudflare', '1234', 'https://iframe.cloudflarestream.com/1234'], + 'file' => ['https://example.com/clip.mp4', 'file', null, 'https://example.com/clip.mp4'], + 'unsupported' => ['https://example.com/nope', 'unsupported', null, null], + ]; + } + + #[Test] + public function the_augmented_value_casts_to_the_original_value_for_backwards_compatibility() + { + $this->assertSame( + 'https://vimeo.com/22439234', + (string) $this->fieldtype()->augment('https://vimeo.com/22439234'), + ); + } + + private function fieldtype() + { + return (new Video)->setField(new Field('test', ['type' => 'video'])); + } +} diff --git a/tests/Modifiers/EmbedUrlTest.php b/tests/Modifiers/EmbedUrlTest.php index 6061c170f3f..645add32196 100644 --- a/tests/Modifiers/EmbedUrlTest.php +++ b/tests/Modifiers/EmbedUrlTest.php @@ -3,6 +3,7 @@ namespace Tests\Modifiers; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fieldtypes\Video\Embed; use Statamic\Modifiers\Modify; use Tests\TestCase; @@ -113,6 +114,20 @@ public function it_ensures_url_with_query_parameters_are_valid() ); } + #[Test] + public function it_gets_the_embed_url_from_an_augmented_video_value() + { + $this->assertEquals( + 'https://iframe.cloudflarestream.com/1234', + $this->embed(Embed::fromValue('cloudflare:1234')), + ); + + $this->assertEquals( + 'https://player.vimeo.com/video/22439234?dnt=1', + $this->embed(Embed::fromValue('https://vimeo.com/22439234')), + ); + } + public function embed($url) { return Modify::value($url)->embedUrl()->fetch(); From c44253e14a839d6a115192d444759788e95ff37c Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:48:30 -0700 Subject: [PATCH 02/12] Accept null in the video embed url helpers --- src/Fieldtypes/Video/Embed.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php index b2b29ee9256..bfb111b2dc4 100644 --- a/src/Fieldtypes/Video/Embed.php +++ b/src/Fieldtypes/Video/Embed.php @@ -114,8 +114,12 @@ public function offsetUnset(mixed $offset) /** * Turn a link that's direct to a video's page into its embeddable equivalent. */ - public static function embedUrl(string $url): string + public static function embedUrl(?string $url): ?string { + if (blank($url)) { + return $url; + } + if (Str::contains($url, self::VIMEO)) { return static::vimeoEmbedUrl($url); } @@ -157,9 +161,9 @@ public static function embedUrl(string $url): string return $url; } - public static function isEmbeddable(string $url): bool + public static function isEmbeddable(?string $url): bool { - return Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]); + return filled($url) && Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]); } protected static function isVideoFile(string $url): bool From 862f032a856c619403f790f5d243f9ee5f19d8b7 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:48:34 -0700 Subject: [PATCH 03/12] Base video embed truthiness on the value, not the provider --- src/Fieldtypes/Video/Embed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php index bfb111b2dc4..872b4465ec4 100644 --- a/src/Fieldtypes/Video/Embed.php +++ b/src/Fieldtypes/Video/Embed.php @@ -75,7 +75,7 @@ public function toArray(): array public function toBool(): bool { - return $this->isSupported(); + return (bool) $this->url; } public function __toString(): string From f17b781fd56320632f65447eea8c2120103b9d41 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:48:41 -0700 Subject: [PATCH 04/12] Pass unsupported video urls through the embed url modifier --- src/Modifiers/CoreModifiers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index 7e9dd96acff..cea5ef1ced9 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -3201,7 +3201,7 @@ public function yearsAgo($value, $params) public function embedUrl($url) { if ($url instanceof Embed) { - return $url->embedUrl; + return $url->embedUrl ?? $url->url; } return Embed::embedUrl($url); From 3745c70f3a3d49330b86eabc3dadb9daff91bd88 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:48:49 -0700 Subject: [PATCH 05/12] Keep trackable embed urls trackable for augmented videos --- src/Modifiers/CoreModifiers.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index cea5ef1ced9..e46ae578ba2 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -3217,7 +3217,11 @@ public function embedUrl($url) public function trackableEmbedUrl($url) { if ($url instanceof Embed) { - return $url->embedUrl; + $url = $url->url; + } + + if (blank($url)) { + return $url; } if (Str::contains($url, 'vimeo')) { From 9240993404af831c90944119b8168726421b324c Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:49:00 -0700 Subject: [PATCH 06/12] Treat video files as supported but not embeddable --- src/Fieldtypes/Video/Embed.php | 7 ++++++- src/Modifiers/CoreModifiers.php | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php index 872b4465ec4..4bd794f205e 100644 --- a/src/Fieldtypes/Video/Embed.php +++ b/src/Fieldtypes/Video/Embed.php @@ -58,6 +58,11 @@ public function __construct( ) { } + public function isEmbeddable(): bool + { + return ! in_array($this->provider, [self::FILE, self::UNSUPPORTED]); + } + public function isSupported(): bool { return $this->provider !== self::UNSUPPORTED; @@ -161,7 +166,7 @@ public static function embedUrl(?string $url): ?string return $url; } - public static function isEmbeddable(?string $url): bool + public static function isEmbeddableUrl(?string $url): bool { return filled($url) && Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]); } diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index e46ae578ba2..a31b63ae21a 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -3257,10 +3257,10 @@ public function trackableEmbedUrl($url) public function isEmbeddable($url) { if ($url instanceof Embed) { - return $url->isSupported(); + return $url->isEmbeddable(); } - return Embed::isEmbeddable($url); + return Embed::isEmbeddableUrl($url); } /** From 83df089ac74f7eee741694c287ae0376d3b6773d Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:49:15 -0700 Subject: [PATCH 07/12] Detect oembed providers before falling back to video files --- src/Fieldtypes/Video/Embed.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php index 4bd794f205e..e342991aa42 100644 --- a/src/Fieldtypes/Video/Embed.php +++ b/src/Fieldtypes/Video/Embed.php @@ -34,14 +34,14 @@ public static function fromValue(?string $value): self : static::unsupported($value); } - if (static::isVideoFile($value)) { - return new self(self::FILE, $value, $value); - } - if ($provider = static::oembedProvider($value)) { return new self($provider, $value, static::embedUrl($value)); } + if (static::isVideoFile($value)) { + return new self(self::FILE, $value, $value); + } + return static::unsupported($value); } From dc360f7fefddc1d76d5552f147e9f02aa7acf707 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:49:29 -0700 Subject: [PATCH 08/12] Keep video fields serializing to a string in the api --- src/Fieldtypes/Video/Embed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php index e342991aa42..1d06b419beb 100644 --- a/src/Fieldtypes/Video/Embed.php +++ b/src/Fieldtypes/Video/Embed.php @@ -91,7 +91,7 @@ public function __toString(): string #[\ReturnTypeWillChange] public function jsonSerialize() { - return $this->toArray(); + return (string) $this; } #[\ReturnTypeWillChange] From 56acf47e3e859fbef8513598a85141b0a89fce29 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 09:50:36 -0700 Subject: [PATCH 09/12] Assert augmented video fields behave like the raw value --- tests/Fieldtypes/Video/ParityTest.php | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/Fieldtypes/Video/ParityTest.php diff --git a/tests/Fieldtypes/Video/ParityTest.php b/tests/Fieldtypes/Video/ParityTest.php new file mode 100644 index 00000000000..e0de27ac11a --- /dev/null +++ b/tests/Fieldtypes/Video/ParityTest.php @@ -0,0 +1,103 @@ +` is a new format + * with no prior behaviour to preserve. VideoTest covers it instead. + */ +class ParityTest extends TestCase +{ + public static function valuesProvider() + { + return [ + 'null' => [null], + 'empty' => [''], + 'unsupported' => ['https://example.com/nope'], + 'youtube' => ['https://www.youtube.com/watch?v=FK3dav4bA4s'], + 'youtube shorts' => ['https://www.youtube.com/shorts/FK3dav4bA4s'], + 'youtu.be' => ['https://youtu.be/FK3dav4bA4s'], + 'vimeo' => ['https://vimeo.com/22439234'], + 'unlisted vimeo' => ['https://vimeo.com/22439234/abcdef'], + 'vimeo progressive file' => ['https://player.vimeo.com/progressive_redirect/playback/123/rendition/1080p/file.mp4?loc=external'], + 'video file' => ['https://example.com/clip.mp4'], + ]; + } + + #[Test] + #[DataProvider('valuesProvider')] + public function the_embed_url_modifier_matches_the_raw_value($value) + { + $this->assertSame( + Modify::value($value)->embedUrl()->fetch(), + Modify::value($this->augment($value))->embedUrl()->fetch(), + ); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function the_trackable_embed_url_modifier_matches_the_raw_value($value) + { + $this->assertSame( + Modify::value($value)->trackableEmbedUrl()->fetch(), + Modify::value($this->augment($value))->trackableEmbedUrl()->fetch(), + ); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function the_is_embeddable_modifier_matches_the_raw_value($value) + { + $this->assertSame( + Modify::value($value)->isEmbeddable()->fetch(), + Modify::value($this->augment($value))->isEmbeddable()->fetch(), + ); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function it_casts_to_the_original_value($value) + { + $this->assertSame((string) $value, (string) $this->augment($value)); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function it_is_as_truthy_as_the_original_value($value) + { + // A null value isn't augmented at all, so there's no object to ask. + if (is_null($augmented = $this->augment($value))) { + $this->assertNull($value); + + return; + } + + $this->assertSame((bool) $value, $augmented->toBool()); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function it_serializes_to_the_original_value($value) + { + $field = (new Video)->setField(new Field('test', ['type' => 'video'])); + + $this->assertSame(json_encode($value), json_encode(new Value($value, 'test', $field))); + } + + private function augment($value) + { + return (new Video)->setField(new Field('test', ['type' => 'video']))->augment($value); + } +} From cdbcc045316066178883ddbd9b2547348cb73de2 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 10:14:34 -0700 Subject: [PATCH 10/12] Update video embed tests for the corrected contract --- tests/Fieldtypes/Video/EmbedTest.php | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/Fieldtypes/Video/EmbedTest.php b/tests/Fieldtypes/Video/EmbedTest.php index 4d23a2c2700..f386e8b5217 100644 --- a/tests/Fieldtypes/Video/EmbedTest.php +++ b/tests/Fieldtypes/Video/EmbedTest.php @@ -49,10 +49,25 @@ public function it_casts_to_the_original_value() } #[Test] - public function it_is_falsey_when_unsupported() + public function it_is_truthy_whenever_it_holds_a_value() { $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->toBool()); - $this->assertFalse(Embed::fromValue('https://example.com/nope')->toBool()); + $this->assertTrue(Embed::fromValue('https://example.com/nope')->toBool()); + $this->assertFalse(Embed::fromValue('')->toBool()); + } + + #[Test] + public function it_knows_whether_it_is_supported_and_embeddable() + { + $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->isEmbeddable()); + $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->isSupported()); + + // A file is something we can play, but not something we can put in an iframe. + $this->assertFalse(Embed::fromValue('https://example.com/clip.mp4')->isEmbeddable()); + $this->assertTrue(Embed::fromValue('https://example.com/clip.mp4')->isSupported()); + + $this->assertFalse(Embed::fromValue('https://example.com/nope')->isEmbeddable()); + $this->assertFalse(Embed::fromValue('https://example.com/nope')->isSupported()); } #[Test] @@ -73,11 +88,9 @@ public function it_is_arrayable_and_accessible_as_an_array() } #[Test] - public function it_serializes_to_json_as_its_array() + public function it_serializes_to_json_as_the_original_value() { - $this->assertSame( - json_encode(Embed::fromValue('cloudflare:1234')->toArray()), - json_encode(Embed::fromValue('cloudflare:1234')), - ); + $this->assertSame('"cloudflare:1234"', json_encode(Embed::fromValue('cloudflare:1234'))); + $this->assertSame('"https:\\/\\/vimeo.com\\/1"', json_encode(Embed::fromValue('https://vimeo.com/1'))); } } From 81c399ab8b353404d355a18a127a68a63d2ab6b1 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 15 Sep 2026 11:00:49 -0700 Subject: [PATCH 11/12] Measure the string length of stringable value objects --- src/Modifiers/CoreModifiers.php | 5 ++++- tests/Modifiers/LengthTest.php | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index a31b63ae21a..1f04fcf8df8 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -36,6 +36,7 @@ use Statamic\Support\Str; use Statamic\Support\Traits\ChecksDumpability; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Stringable; use Stringy\StaticStringy as Stringy; use function Statamic\trans; @@ -1561,7 +1562,9 @@ public function length($value) return $value->count(); } - if ($value instanceof Arrayable) { + // Value objects like ArrayableString are both Arrayable and Stringable. + // They stand in for a string, so measure the string, not the array. + if ($value instanceof Arrayable && ! $value instanceof Stringable) { $value = $value->toArray(); } diff --git a/tests/Modifiers/LengthTest.php b/tests/Modifiers/LengthTest.php index 826d7168dc6..117c39c43fe 100644 --- a/tests/Modifiers/LengthTest.php +++ b/tests/Modifiers/LengthTest.php @@ -6,6 +6,7 @@ use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Query\Builder; +use Statamic\Fields\ArrayableString; use Statamic\Modifiers\Modify; use Tests\TestCase; @@ -47,12 +48,26 @@ public function it_returns_the_number_of_items_in_a_query() #[Test] public function it_returns_the_number_of_items_in_an_arrayable() { - $arrayable = Mockery::mock(Arrayable::class)->shouldReceive('toArray')->andReturn(['one', 'two'])->getMock(); + $arrayable = new class implements Arrayable + { + public function toArray() + { + return ['one', 'two']; + } + }; $modified = $this->modify($arrayable); $this->assertSame(2, $modified); } + #[Test] + public function it_returns_the_number_of_chars_in_a_stringable_arrayable() + { + // Value objects like ArrayableString stand in for a string, so the + // string is what should get measured, not their array form. + $this->assertSame(19, $this->modify(new ArrayableString('https://vimeo.com/1'))); + } + #[Test] public function it_returns_the_numbers_of_chars_in_string(): void { From b752d12d3aa0ad685759eab752d21738b167e4f8 Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 14 Sep 2026 15:41:26 -0700 Subject: [PATCH 12/12] Add Cloudflare Stream support to the video fieldtype --- .../components/fieldtypes/VideoFieldtype.vue | 58 ++++++++++++- .../fieldtypes/VideoFieldtype.test.js | 82 +++++++++++++++++-- src/Fieldtypes/Video.php | 8 ++ src/Fieldtypes/Video/Embed.php | 11 +++ tests/Fieldtypes/VideoTest.php | 27 +++++- 5 files changed, 174 insertions(+), 12 deletions(-) diff --git a/resources/js/components/fieldtypes/VideoFieldtype.vue b/resources/js/components/fieldtypes/VideoFieldtype.vue index 5282a8b1a3a..3965a5e0d5c 100644 --- a/resources/js/components/fieldtypes/VideoFieldtype.vue +++ b/resources/js/components/fieldtypes/VideoFieldtype.vue @@ -1,6 +1,26 @@