diff --git a/CHANGELOG.md b/CHANGELOG.md index e15f58dd1b..e6ff55efde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi ## [Unreleased][unreleased] +### Added + + - Added new `EmbedExtension` (#805) + ## [2.2.1] - 2022-01-25 ### Fixed diff --git a/composer.json b/composer.json index a05bba90cb..49340e8c65 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "league/config": "^1.1.1", "psr/event-dispatcher": "^1.0", "symfony/deprecation-contracts": "^2.1 || ^3.0", - "symfony/polyfill-php80": "^1.15" + "symfony/polyfill-php80": "^1.16" }, "require-dev": { "ext-json": "*", @@ -34,9 +34,11 @@ "commonmark/cmark": "0.30.0", "commonmark/commonmark.js": "0.30.0", "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", "erusev/parsedown": "^1.0", "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4", + "nyholm/psr7": "^1.5", "phpstan/phpstan": "^0.12.88 || ^1.0.0", "phpunit/phpunit": "^9.5.5", "scrutinizer/ocular": "^1.8.1", diff --git a/docs/2.3/extensions/embed.md b/docs/2.3/extensions/embed.md new file mode 100644 index 0000000000..517ea67e69 --- /dev/null +++ b/docs/2.3/extensions/embed.md @@ -0,0 +1,152 @@ +--- +layout: default +title: Embed Extension +description: The EmbedExtension supports embedding rich content from other websites. +--- + +# Embed Extension + +This extension can embed rich content (like videos, tweets, etc.) from other websites. + +The syntax is very simple - simply place any `https://` URL on its own line like this: + +```md +Check out this video! + +https://www.youtube.com/watch?v=dQw4w9WgXcQ +``` + +If the link points to embeddable content, it will be replaced with the rich HTML needed to embed it: + +```html +

Check out this video:

+ +``` + +## Installation + +This extension is bundled with `league/commonmark`. This library can be installed via Composer: + +```bash +composer require league/commonmark +``` + +You'll also need to install a third-party [OEmbed](https://www.oembed.com/) library - see the [**Adapter**](#adapter) section below. + +## Usage + +Configure your `Environment` as usual and add the `EmbedExtension` provided by this package: + +```php +use Embed\Embed; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Embed\EmbedExtension; +use League\CommonMark\MarkdownConverter; + +// Define your configuration +$config = [ + 'embed' => [ + 'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below + 'allowed_domains' => ['youtube.com', 'twitter.com', 'github.com'], + 'fallback' => 'link', + ], +]; + +// Configure the Environment with all whatever other extensions you want +$environment = new Environment($config); +$environment->addExtension(new CommonMarkCoreExtension()); + +// Add this extension +$environment->addExtension(new EmbedExtension()); + +// Instantiate the converter engine and start converting some Markdown! +$converter = new MarkdownConverter($environment); +echo $converter->convert('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); +``` + +## Configuration + +This extension supports the following configuration options under the `embed` configuration: + +### `adapter` option + +Any instance of `EmbedAdapterInterface` - see the "**[Adapter](#adapter)**" section below. + +### `allowed_domains` option + +This option defines a list of hosts that you wish to allow embedding content from. For example, setting this to +`['youtube.com']` would only allow videos from YouTube to be embedded. +It's extremely important that you only include websites you trust since they'll be providing HTML that is directly embedded in your website. + +Any subdomains of these domains will also be allowed. For example, `['youtube.com']` would allow embedding from `youtube.com` or `www.youtube.com`. + +As an additional safety measure, we recommend that you also use a [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) +to prevent unexpected content from being embedded. + +By default, this option is an empty array (`[]`), which means that all domains are allowed. + +### `fallback` option + +This options defines the behavior when a URL cannot be embedded, either because it's not in the list of `allowed_domains`, +or because the `adapter` could not find embeddable content for that URL. + +There are two possible values for this option: + +- `'link'` - the URL will be kept in the document as a link (**default**) +-`'remove'` - the URL will be completely removed from the document + +## Adapter + +`league/commonmark` doesn't know how to obtain the embeddable HTML for a given URL - this must be done by an external library. + +### `embed/embed` Adapter + +We do provide an adapter for the popular [`embed/embed`](https://github.com/oscarotero/Embed) library. if you'd like to use that. We like this library +because it supports fetching multiple URLs in parallel, which is ideal for performance, and it supports a wide range +of embeddable content. + +To use that library, you'll need to `composer install embed/embed` and then pass `new OscaroteroEmbedAdapter()` as the `adapter` +configuration option, as shown in the [**Usage**](#usage) section above. + +Need to customize the maximum width/height of the embedded content? You can do that by instantiating the service provided by +`embed/embed`, [configuring it as needed](https://github.com/oscarotero/Embed#settings), and passing that customized instance into the adapter: + +```php +use Embed\Embed; +use League\CommonMark\Extension\Embed\Bridge\OscaroteroEmbedAdapter; + +// Configure the Embed library itself +$embedLibrary = new Embed(); +$embedLibrary->setSettings([ + 'oembed:query_parameters' => [ + 'maxwidth' => 800, + 'maxheight' => 600, + ], + 'twitch:parent' => 'example.com', + 'facebook:token' => '1234|5678', + 'instagram:token' => '1234|5678', + 'twitter:token' => 'asdf', +]); + +// Inject it into our adapter +$config = [ + 'adapter' => new OscaroteroEmbedAdapter($embedLibrary), +]; + +// Instantiate your CommonMark environment and converter like usual +// ... +``` + +### Custom Adapter + +If you prefer to use a different library, you'll need to implement our `EmbedAdapterInterface` yourself with +[whatever OEmbed library](https://packagist.org/?tags=oembed) you choose. + +## Tips + +If you need to wrap the HTML in a container tag, consider using the [`HtmlDecorator` renderer](/2.3/customization/rendering/#wrapping-elements-with-htmldecorator): + +```php +$environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embeded-content'])); +``` diff --git a/docs/2.3/extensions/overview.md b/docs/2.3/extensions/overview.md index 1217e165ce..8000d77e48 100644 --- a/docs/2.3/extensions/overview.md +++ b/docs/2.3/extensions/overview.md @@ -22,6 +22,7 @@ to enhance your experience out-of-the-box depending on your specific use-cases. | [Default Attributes] | Easily apply default HTML classes using configuration options to match your site's styles | `2.0.0` | | | [Description Lists] | Create `
` description lists using Markdown Extra's syntax | `2.0.0` | | | [Disallowed Raw HTML] | Disables certain kinds of HTML tags that could affect page rendering | `1.3.0` | | +| [Embed] | Embed rich content (like videos, tweets, and more) from other websites | `2.3.0` | | | [External Links] | Tags external links with additional markup | `1.3.0` | | | [Footnotes] | Add footnote references throughout the document and show a listing of them at the bottom | `1.5.0` | | | [Front Matter] | Parses YAML front matter from your Markdown input | `2.0.0` | | @@ -102,6 +103,7 @@ See the [Custom Extensions](/2.3/customization/extensions/) page for details on [Default Attributes]: /2.3/extensions/default-attributes/ [Description Lists]: /2.3/extensions/description-lists/ [Disallowed Raw HTML]: /2.3/extensions/disallowed-raw-html/ +[Embed]: /2.3/extensions/embed/ [External Links]: /2.3/extensions/external-links/ [Footnotes]: /2.3/extensions/footnotes/ [Front Matter]: /2.3/extensions/front-matter/ diff --git a/docs/_data/menu.yml b/docs/_data/menu.yml index 43900f21f2..ba92374502 100644 --- a/docs/_data/menu.yml +++ b/docs/_data/menu.yml @@ -20,6 +20,7 @@ version: 'Default Attributes': '/2.3/extensions/default-attributes/' 'Description Lists': '/2.3/extensions/description-lists/' 'Disallowed Raw HTML': '/2.3/extensions/disallowed-raw-html/' + 'Embed': '/2.3/extensions/embed/' 'External Links': '/2.3/extensions/external-links/' 'Footnotes': '/2.3/extensions/footnotes/' 'Heading Permalinks': '/2.3/extensions/heading-permalinks/' diff --git a/src/Extension/Embed/Bridge/OscaroteroEmbedAdapter.php b/src/Extension/Embed/Bridge/OscaroteroEmbedAdapter.php new file mode 100644 index 0000000000..5335eea59e --- /dev/null +++ b/src/Extension/Embed/Bridge/OscaroteroEmbedAdapter.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed\Bridge; + +use Embed\Embed as EmbedLib; +use League\CommonMark\Extension\Embed\Embed; +use League\CommonMark\Extension\Embed\EmbedAdapterInterface; + +final class OscaroteroEmbedAdapter implements EmbedAdapterInterface +{ + private EmbedLib $embedLib; + + public function __construct(?EmbedLib $embed = null) + { + if ($embed === null) { + if (! \class_exists(EmbedLib::class)) { + throw new \RuntimeException('The embed/embed package is not installed. Please install it with Composer to use this adapter.'); + } + + $embed = new EmbedLib(); + } + + $this->embedLib = $embed; + } + + /** + * {@inheritDoc} + */ + public function updateEmbeds(array $embeds): void + { + $extractors = $this->embedLib->getMulti(...\array_map(static fn (Embed $embed) => $embed->getUrl(), $embeds)); + foreach ($extractors as $i => $extractor) { + if ($extractor->code !== null) { + $embeds[$i]->setEmbedCode($extractor->code->html); + } + } + } +} diff --git a/src/Extension/Embed/DomainFilteringAdapter.php b/src/Extension/Embed/DomainFilteringAdapter.php new file mode 100644 index 0000000000..c42100f222 --- /dev/null +++ b/src/Extension/Embed/DomainFilteringAdapter.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +class DomainFilteringAdapter implements EmbedAdapterInterface +{ + private EmbedAdapterInterface $decorated; + + private string $regex; + + /** + * @param string[] $allowedDomains + */ + public function __construct(EmbedAdapterInterface $decorated, array $allowedDomains) + { + $this->decorated = $decorated; + $this->regex = self::createRegex($allowedDomains); + } + + /** + * {@inheritDoc} + */ + public function updateEmbeds(array $embeds): void + { + $this->decorated->updateEmbeds(\array_filter($embeds, function (Embed $embed): bool { + return \preg_match($this->regex, $embed->getUrl()) === 1; + })); + } + + /** + * @param string[] $allowedDomains + */ + private static function createRegex(array $allowedDomains): string + { + $allowedDomains = \array_map('preg_quote', $allowedDomains); + + return '/^(?:https?:\/\/)?(?:[^.]+\.)*(' . \implode('|', $allowedDomains) . ')/'; + } +} diff --git a/src/Extension/Embed/Embed.php b/src/Extension/Embed/Embed.php new file mode 100644 index 0000000000..94c1980453 --- /dev/null +++ b/src/Extension/Embed/Embed.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +use League\CommonMark\Node\Block\AbstractBlock; + +final class Embed extends AbstractBlock +{ + private string $url; + private ?string $embedCode; + + public function __construct(string $url, ?string $embedCode = null) + { + parent::__construct(); + + $this->url = $url; + $this->embedCode = $embedCode; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setUrl(string $url): void + { + $this->url = $url; + } + + public function getEmbedCode(): ?string + { + return $this->embedCode; + } + + public function setEmbedCode(?string $embedCode): void + { + $this->embedCode = $embedCode; + } +} diff --git a/src/Extension/Embed/EmbedAdapterInterface.php b/src/Extension/Embed/EmbedAdapterInterface.php new file mode 100644 index 0000000000..9880a4393d --- /dev/null +++ b/src/Extension/Embed/EmbedAdapterInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +/** + * Interface for a service which updates the embed code(s) for the given array of embeds + */ +interface EmbedAdapterInterface +{ + /** + * @param Embed[] $embeds + */ + public function updateEmbeds(array $embeds): void; +} diff --git a/src/Extension/Embed/EmbedExtension.php b/src/Extension/Embed/EmbedExtension.php new file mode 100644 index 0000000000..616a837813 --- /dev/null +++ b/src/Extension/Embed/EmbedExtension.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +use League\CommonMark\Environment\EnvironmentBuilderInterface; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\ConfigurableExtensionInterface; +use League\Config\ConfigurationBuilderInterface; +use Nette\Schema\Expect; + +final class EmbedExtension implements ConfigurableExtensionInterface +{ + public function configureSchema(ConfigurationBuilderInterface $builder): void + { + $builder->addSchema('embed', Expect::structure([ + 'adapter' => Expect::type(EmbedAdapterInterface::class), + 'allowed_domains' => Expect::arrayOf('string')->default([]), + 'fallback' => Expect::anyOf('link', 'remove')->default('link'), + ])); + } + + public function register(EnvironmentBuilderInterface $environment): void + { + $adapter = $environment->getConfiguration()->get('embed.adapter'); + \assert($adapter instanceof EmbedAdapterInterface); + + $allowedDomains = $environment->getConfiguration()->get('embed.allowed_domains'); + if ($allowedDomains !== []) { + $adapter = new DomainFilteringAdapter($adapter, $allowedDomains); + } + + $environment + ->addBlockStartParser(new EmbedStartParser(), 300) + ->addEventListener(DocumentParsedEvent::class, new EmbedProcessor($adapter, $environment->getConfiguration()->get('embed.fallback'))) + ->addRenderer(Embed::class, new EmbedRenderer()); + } +} diff --git a/src/Extension/Embed/EmbedParser.php b/src/Extension/Embed/EmbedParser.php new file mode 100644 index 0000000000..e957caf82b --- /dev/null +++ b/src/Extension/Embed/EmbedParser.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +use League\CommonMark\Node\Block\AbstractBlock; +use League\CommonMark\Parser\Block\BlockContinue; +use League\CommonMark\Parser\Block\BlockContinueParserInterface; +use League\CommonMark\Parser\Cursor; + +class EmbedParser implements BlockContinueParserInterface +{ + private Embed $embed; + + public function __construct(string $url) + { + $this->embed = new Embed($url); + } + + public function getBlock(): AbstractBlock + { + return $this->embed; + } + + public function isContainer(): bool + { + return false; + } + + public function canHaveLazyContinuationLines(): bool + { + return false; + } + + public function canContain(AbstractBlock $childBlock): bool + { + return false; + } + + public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue + { + return BlockContinue::none(); + } + + public function addLine(string $line): void + { + } + + public function closeBlock(): void + { + } +} diff --git a/src/Extension/Embed/EmbedProcessor.php b/src/Extension/Embed/EmbedProcessor.php new file mode 100644 index 0000000000..5df099eb66 --- /dev/null +++ b/src/Extension/Embed/EmbedProcessor.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; +use League\CommonMark\Node\Block\Paragraph; +use League\CommonMark\Node\NodeIterator; + +final class EmbedProcessor +{ + public const FALLBACK_REMOVE = 'remove'; + public const FALLBACK_LINK = 'link'; + + private EmbedAdapterInterface $adapter; + private string $fallback; + + public function __construct(EmbedAdapterInterface $adapter, string $fallback = self::FALLBACK_REMOVE) + { + $this->adapter = $adapter; + $this->fallback = $fallback; + } + + public function __invoke(DocumentParsedEvent $event): void + { + $embeds = []; + foreach (new NodeIterator($event->getDocument()) as $node) { + if ($node instanceof Embed) { + $embeds[] = $node; + } + } + + $this->adapter->updateEmbeds($embeds); + + foreach ($embeds as $embed) { + if ($embed->getEmbedCode() !== null) { + continue; + } + + if ($this->fallback === self::FALLBACK_REMOVE) { + $embed->detach(); + } elseif ($this->fallback === self::FALLBACK_LINK) { + $paragraph = new Paragraph(); + $paragraph->appendChild(new Link($embed->getUrl(), $embed->getUrl())); + $embed->replaceWith($paragraph); + } + } + } +} diff --git a/src/Extension/Embed/EmbedRenderer.php b/src/Extension/Embed/EmbedRenderer.php new file mode 100644 index 0000000000..91655d88f2 --- /dev/null +++ b/src/Extension/Embed/EmbedRenderer.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; + +class EmbedRenderer implements NodeRendererInterface +{ + /** + * @param Embed $node + * + * {@inheritDoc} + * + * @psalm-suppress MoreSpecificImplementedParamType + */ + public function render(Node $node, ChildNodeRendererInterface $childRenderer) + { + Embed::assertInstanceOf($node); + + return $node->getEmbedCode() ?? ''; + } +} diff --git a/src/Extension/Embed/EmbedStartParser.php b/src/Extension/Embed/EmbedStartParser.php new file mode 100644 index 0000000000..951e212d80 --- /dev/null +++ b/src/Extension/Embed/EmbedStartParser.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Embed; + +use League\CommonMark\Node\Block\Document; +use League\CommonMark\Parser\Block\BlockStart; +use League\CommonMark\Parser\Block\BlockStartParserInterface; +use League\CommonMark\Parser\Cursor; +use League\CommonMark\Parser\MarkdownParserStateInterface; +use League\CommonMark\Util\LinkParserHelper; + +class EmbedStartParser implements BlockStartParserInterface +{ + public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart + { + if ($cursor->isIndented() || $parserState->getParagraphContent() !== null || ! ($parserState->getLastMatchedBlockParser()->getBlock() instanceof Document)) { + return BlockStart::none(); + } + + // 0-3 leading spaces are okay + $cursor->advanceToNextNonSpaceOrTab(); + + // The line must begin with "https://" + if (! str_starts_with($cursor->getRemainder(), 'https://')) { + return BlockStart::none(); + } + + // A valid link must be found next + if (($dest = LinkParserHelper::parseLinkDestination($cursor)) === null) { + return BlockStart::none(); + } + + // Skip any trailing whitespace + $cursor->advanceToNextNonSpaceOrTab(); + + // We must be at the end of the line; otherwise, this link was not by itself + if (! $cursor->isAtEnd()) { + return BlockStart::none(); + } + + return BlockStart::of(new EmbedParser($dest))->at($cursor); + } +} diff --git a/tests/functional/Extension/Embed/Bridge/LocalFileClient.php b/tests/functional/Extension/Embed/Bridge/LocalFileClient.php new file mode 100644 index 0000000000..defdff6a76 --- /dev/null +++ b/tests/functional/Extension/Embed/Bridge/LocalFileClient.php @@ -0,0 +1,102 @@ + + * + * Adapted from the embed/embed test suite, + * (c) 2017 Oscar Otero Marzoa, used under the MIT license. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Tests\Functional\Extension\Embed\Bridge; + +use Embed\Http\CurlClient; +use Embed\Http\FactoryDiscovery; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; + +/** + * Decorator to cache requests into files + */ +final class LocalFileClient implements ClientInterface +{ + private string $path; + private ResponseFactoryInterface $responseFactory; + private ClientInterface $client; + + public function __construct(string $path) + { + $this->path = $path; + $this->responseFactory = FactoryDiscovery::getResponseFactory(); + $this->client = new CurlClient($this->responseFactory); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $uri = $request->getUri(); + $filename = $this->path . '/' . self::getFilename($uri); + + if (\is_file($filename)) { + $response = $this->readResponse($filename); + } else { + $response = $this->client->sendRequest($request); + $this->saveResponse($response, $filename); + } + + return $response; + } + + public static function getFilename(UriInterface $uri): string + { + $query = $uri->getQuery(); + + return \sprintf( + '%s.%s%s.json', + $uri->getHost(), + \trim(\preg_replace('/[^\w.-]+/', '-', \strtolower($uri->getPath())) ?? '', '-'), + $query ? '.' . \md5($uri->getQuery()) : '' + ); + } + + private function readResponse(string $filename): ResponseInterface + { + $file = \file_get_contents($filename); + if ($file === false) { + throw new \InvalidArgumentException(\sprintf('Unable to read file "%s"', $filename)); + } + + $message = \json_decode($file, true, JSON_THROW_ON_ERROR); + $response = $this->responseFactory->createResponse($message['statusCode'], $message['reasonPhrase']); + + foreach ($message['headers'] as $name => $value) { + $response = $response->withHeader($name, $value); + } + + $body = $response->getBody(); + $body->write($message['body']); + $body->rewind(); + + return $response; + } + + private function saveResponse(ResponseInterface $response, string $filename): void + { + $message = [ + 'headers' => $response->getHeaders(), + 'statusCode' => $response->getStatusCode(), + 'reasonPhrase' => $response->getReasonPhrase(), + 'body' => (string) $response->getBody(), + ]; + + \file_put_contents($filename, \json_encode($message, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/functional/Extension/Embed/Bridge/OscaroteroEmbedAdapterTest.php b/tests/functional/Extension/Embed/Bridge/OscaroteroEmbedAdapterTest.php new file mode 100644 index 0000000000..d29b5f1d58 --- /dev/null +++ b/tests/functional/Extension/Embed/Bridge/OscaroteroEmbedAdapterTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Tests\Functional\Extension\Embed\Bridge; + +use Embed\Embed as EmbedLib; +use Embed\Http\Crawler; +use League\CommonMark\ConverterInterface; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Embed\Bridge\OscaroteroEmbedAdapter; +use League\CommonMark\Extension\Embed\EmbedExtension; +use League\CommonMark\MarkdownConverter; +use League\CommonMark\Tests\Functional\AbstractLocalDataTest; + +final class OscaroteroEmbedAdapterTest extends AbstractLocalDataTest +{ + /** + * {@inheritDoc} + */ + protected function createConverter(array $config = []): ConverterInterface + { + $config['embed']['adapter'] = new OscaroteroEmbedAdapter(new EmbedLib(new Crawler(new LocalFileClient(__DIR__ . '/requests')))); + + $environment = new Environment($config); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new EmbedExtension()); + + return new MarkdownConverter($environment); + } + + /** + * {@inheritDoc} + */ + public function dataProvider(): iterable + { + yield from $this->loadTests(__DIR__ . '/data'); + } +} diff --git a/tests/functional/Extension/Embed/Bridge/data/youtube.html b/tests/functional/Extension/Embed/Bridge/data/youtube.html new file mode 100644 index 0000000000..eb53c873f1 --- /dev/null +++ b/tests/functional/Extension/Embed/Bridge/data/youtube.html @@ -0,0 +1,2 @@ +

Check out this video:

+ diff --git a/tests/functional/Extension/Embed/Bridge/data/youtube.md b/tests/functional/Extension/Embed/Bridge/data/youtube.md new file mode 100644 index 0000000000..2ca332e9ee --- /dev/null +++ b/tests/functional/Extension/Embed/Bridge/data/youtube.md @@ -0,0 +1,3 @@ +Check out this video: + +https://www.youtube.com/watch?v=dQw4w9WgXcQ diff --git a/tests/functional/Extension/Embed/Bridge/requests/www.youtube.com.oembed.2879fe17dbfe688cfce2eef49bdcbde7.json b/tests/functional/Extension/Embed/Bridge/requests/www.youtube.com.oembed.2879fe17dbfe688cfce2eef49bdcbde7.json new file mode 100644 index 0000000000..1fe850340b --- /dev/null +++ b/tests/functional/Extension/Embed/Bridge/requests/www.youtube.com.oembed.2879fe17dbfe688cfce2eef49bdcbde7.json @@ -0,0 +1,48 @@ +{ + "headers": { + "content-type": [ + "application\/json" + ], + "vary": [ + "Origin", + "X-Origin", + "Referer" + ], + "content-encoding": [ + "gzip" + ], + "date": [ + "Sat, 05 Feb 2022 22:51:33 GMT" + ], + "server": [ + "scaffolding on HTTPServer2" + ], + "cache-control": [ + "private" + ], + "content-length": [ + "405" + ], + "x-xss-protection": [ + "0" + ], + "x-frame-options": [ + "SAMEORIGIN" + ], + "x-content-type-options": [ + "nosniff" + ], + "alt-svc": [ + "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"" + ], + "Content-Location": [ + "https:\/\/www.youtube.com\/oembed?format=json&url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ" + ], + "X-Request-Time": [ + "0.076 ms" + ] + }, + "statusCode": 200, + "reasonPhrase": "OK", + "body": "{\"title\":\"Rick Astley - Never Gonna Give You Up (Official Music Video)\",\"author_name\":\"Rick Astley\",\"author_url\":\"https:\/\/www.youtube.com\/c\/RickastleyCoUkOfficial\",\"type\":\"video\",\"height\":113,\"width\":200,\"version\":\"1.0\",\"provider_name\":\"YouTube\",\"provider_url\":\"https:\/\/www.youtube.com\/\",\"thumbnail_height\":360,\"thumbnail_width\":480,\"thumbnail_url\":\"https:\/\/i.ytimg.com\/vi\/dQw4w9WgXcQ\/hqdefault.jpg\",\"html\":\"\\u003ciframe width=\\u0022200\\u0022 height=\\u0022113\\u0022 src=\\u0022https:\/\/www.youtube.com\/embed\/dQw4w9WgXcQ?feature=oembed\\u0022 frameborder=\\u00220\\u0022 allow=\\u0022accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\\u0022 allowfullscreen\\u003e\\u003c\/iframe\\u003e\"}" +} \ No newline at end of file diff --git a/tests/functional/Extension/Embed/Bridge/requests/www.youtube.com.watch.04c89f4aa4477375b9a0ca246fd32cca.json b/tests/functional/Extension/Embed/Bridge/requests/www.youtube.com.watch.04c89f4aa4477375b9a0ca246fd32cca.json new file mode 100644 index 0000000000..fb170b5704 --- /dev/null +++ b/tests/functional/Extension/Embed/Bridge/requests/www.youtube.com.watch.04c89f4aa4477375b9a0ca246fd32cca.json @@ -0,0 +1,58 @@ +{ + "headers": { + "content-type": [ + "text\/html; charset=utf-8" + ], + "x-content-type-options": [ + "nosniff" + ], + "cache-control": [ + "no-cache, no-store, max-age=0, must-revalidate" + ], + "pragma": [ + "no-cache" + ], + "expires": [ + "Mon, 01 Jan 1990 00:00:00 GMT" + ], + "date": [ + "Sat, 05 Feb 2022 22:51:32 GMT" + ], + "x-frame-options": [ + "SAMEORIGIN" + ], + "strict-transport-security": [ + "max-age=31536000" + ], + "report-to": [ + "{\"group\":\"ATmXEA_XZXH6CdbrmjUzyTbVgxu22C8KYH7NsxKbRt94\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https:\/\/csp.withgoogle.com\/csp\/report-to\/encsid_ATmXEA_XZXH6CdbrmjUzyTbVgxu22C8KYH7NsxKbRt94\"}]}" + ], + "cross-origin-opener-policy-report-only": [ + "same-origin; report-to=\"ATmXEA_XZXH6CdbrmjUzyTbVgxu22C8KYH7NsxKbRt94\"" + ], + "permissions-policy": [ + "ch-ua-arch=*, ch-ua-bitness=*, ch-ua-full-version=*, ch-ua-full-version-list=*, ch-ua-model=*, ch-ua-platform=*, ch-ua-platform-version=*" + ], + "content-encoding": [ + "br" + ], + "server": [ + "ESF" + ], + "x-xss-protection": [ + "0" + ], + "alt-svc": [ + "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"" + ], + "Content-Location": [ + "https:\/\/www.youtube.com\/watch?v=dQw4w9WgXcQ" + ], + "X-Request-Time": [ + "1.143 ms" + ] + }, + "statusCode": 200, + "reasonPhrase": "OK", + "body": "