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 @@
+