Compare commits
11 Commits
692f4d5492
...
def78d4754
| Author | SHA1 | Date | |
|---|---|---|---|
| def78d4754 | |||
| 81efac4a53 | |||
| f26b930b5f | |||
| 6ee4dcfc21 | |||
| 9486d97c73 | |||
| 6debaf93bc | |||
| 5b6e344ee9 | |||
| bb9843fd47 | |||
| 7e445eb2fe | |||
| 6daa001388 | |||
| 1563aff964 |
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,23 @@ Pure URL classification class with no external dependencies. Highly testable.
|
||||
Thin glue layer. Receives `DocumentParsedEvent`, walks the AST, and delegates URL classification to `MediaUrlResolver`.
|
||||
|
||||
- Public API: `handle(DocumentParsedEvent $event): void`
|
||||
- For each `Image` node: call resolver; if non-null, replace node with `HtmlInline`
|
||||
- For each `Image` node: call resolver; if non-null, replace node with a `MediaEmbedNode`
|
||||
|
||||
#### `src/app/Markdown/MediaEmbedNode.php`
|
||||
|
||||
Custom AST node that carries the pre-rendered embed HTML string.
|
||||
|
||||
- Extends `AbstractStringContainer`
|
||||
- Does NOT implement `RawMarkupContainerInterface` — this is intentional so the node is not subject to `HtmlFilter`
|
||||
- Holds its literal content (the HTML string) for direct output by its renderer
|
||||
|
||||
#### `src/app/Markdown/MediaEmbedNodeRenderer.php`
|
||||
|
||||
Dedicated renderer for `MediaEmbedNode`.
|
||||
|
||||
- Implements `NodeRendererInterface`
|
||||
- Returns the node's literal content directly, without invoking any HTML filter
|
||||
- This is the mechanism that allows trusted embed HTML to survive the `html_input => 'strip'` policy
|
||||
|
||||
### Modified files
|
||||
|
||||
@@ -206,11 +222,15 @@ All output URLs are passed through `htmlspecialchars($url, ENT_QUOTES, 'UTF-8')`
|
||||
|
||||
### Relation to `html_input => 'strip'`
|
||||
|
||||
The `'strip'` setting is preserved. CommonMark strips raw HTML written by users in source Markdown. However, `HtmlInline` nodes inserted programmatically by a registered extension are not stripped. Therefore:
|
||||
The `'strip'` setting is preserved. All `HtmlInline` nodes — whether written by the user in Markdown source or inserted programmatically by an extension — go through `HtmlFilter::filter()`, which strips their content under `'strip'` mode. To emit the embed HTML safely without bypassing this policy, the extension introduces a custom node type:
|
||||
|
||||
- User-written `<script>` in Markdown source → still stripped
|
||||
- `<video>` / `<iframe>` inserted by `MediaEmbedExtension` → output as intended
|
||||
- The security boundary becomes "the extension is responsible for its own escaping," which is enforced by passing all dynamic URL fragments through `htmlspecialchars`.
|
||||
- `MediaEmbedNode` extends `AbstractStringContainer` and deliberately does NOT implement `RawMarkupContainerInterface`
|
||||
- `MediaEmbedNodeRenderer` returns the node's literal content directly, without invoking any HTML filter
|
||||
|
||||
Therefore:
|
||||
- User-written `<script>` in Markdown source → produces `HtmlInline` → still stripped
|
||||
- `<video>` / `<audio>` / `<iframe>` inserted by `MediaEmbedExtension` → produces `MediaEmbedNode` → output as intended
|
||||
- The security boundary is "only the explicitly trusted node type bypasses filtering," and that node type is reachable only through `MediaEmbedListener` after `MediaUrlResolver` has classified the URL as a known media pattern.
|
||||
|
||||
### `alt` and `title`
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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']);
|
||||
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Event\DocumentParsedEvent;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||
|
||||
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 MediaEmbedNode($html));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||
|
||||
/**
|
||||
* A custom inline node for programmatically generated media embeds.
|
||||
*
|
||||
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
|
||||
* so its renderer bypasses the html_input filter entirely, allowing us to emit
|
||||
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
|
||||
*/
|
||||
class MediaEmbedNode extends AbstractStringContainer
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Node\Node;
|
||||
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||
|
||||
/**
|
||||
* Renders a MediaEmbedNode by emitting its literal content directly,
|
||||
* without going through any html_input filtering.
|
||||
*/
|
||||
class MediaEmbedNodeRenderer implements NodeRendererInterface
|
||||
{
|
||||
/**
|
||||
* @param MediaEmbedNode $node
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @psalm-suppress MoreSpecificImplementedParamType
|
||||
*/
|
||||
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
|
||||
{
|
||||
MediaEmbedNode::assertInstanceOf($node);
|
||||
|
||||
return $node->getLiteral();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
class MediaUrlResolver
|
||||
{
|
||||
private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
|
||||
|
||||
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)
|
||||
?? $this->detectYouTube($url)
|
||||
?? $this->detectVimeo($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 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>";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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>';
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ public static function renderMarkdown(string $markdown): string
|
||||
]);
|
||||
|
||||
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
||||
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
|
||||
|
||||
return $converter->convert($markdown)->getContent();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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'],
|
||||
];
|
||||
}
|
||||
|
||||
#[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>'],
|
||||
];
|
||||
}
|
||||
|
||||
#[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],
|
||||
];
|
||||
}
|
||||
|
||||
#[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'));
|
||||
}
|
||||
|
||||
#[DataProvider('vimeoFalsePositives')]
|
||||
public function test_vimeo_false_positives_return_null(string $url): void
|
||||
{
|
||||
$this->assertNull($this->resolver->resolve($url));
|
||||
}
|
||||
|
||||
public static function vimeoFalsePositives(): array
|
||||
{
|
||||
return [
|
||||
'digits then letter' => ['https://vimeo.com/123abc'],
|
||||
'digits then x' => ['https://vimeo.com/123x'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user