core/modules/media/css/filter.caption.css | 10 +
core/modules/media/media.libraries.yml | 8 +
.../modules/media/src/Plugin/Filter/MediaEmbed.php | 307 ++++++++++++++++++
.../media_test_filter/media_test_filter.info.yml | 8 +
.../media_test_filter/media_test_filter.module | 25 ++
.../media/tests/src/Functional/FilterTest.php | 148 +++++++++
.../tests/src/Kernel/MediaEmbedFilterTest.php | 358 +++++++++++++++++++++
.../tests/src/Kernel/MediaEmbedFilterTestBase.php | 249 ++++++++++++++
core/themes/stable/css/media/filter.caption.css | 10 +
core/themes/stable/stable.info.yml | 5 +
10 files changed, 1128 insertions(+)
diff --git a/core/modules/media/css/filter.caption.css b/core/modules/media/css/filter.caption.css
new file mode 100644
index 0000000000..a92505c308
--- /dev/null
+++ b/core/modules/media/css/filter.caption.css
@@ -0,0 +1,10 @@
+/**
+ * @file
+ * Caption filter: default styling for displaying Media Embed captions.
+ */
+
+.caption .media .field,
+.caption .media .field * {
+ float: none;
+ margin: unset;
+}
diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml
index 6123186360..686e774521 100644
--- a/core/modules/media/media.libraries.yml
+++ b/core/modules/media/media.libraries.yml
@@ -23,3 +23,11 @@ oembed.frame:
css:
component:
css/oembed.frame.css: {}
+
+filter.caption:
+ version: VERSION
+ css:
+ component:
+ css/filter.caption.css: {}
+ dependencies:
+ - filter/caption
diff --git a/core/modules/media/src/Plugin/Filter/MediaEmbed.php b/core/modules/media/src/Plugin/Filter/MediaEmbed.php
new file mode 100644
index 0000000000..5d967a02da
--- /dev/null
+++ b/core/modules/media/src/Plugin/Filter/MediaEmbed.php
@@ -0,0 +1,307 @@
+entityRepository = $entity_repository;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->renderer = $renderer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('entity.repository'),
+ $container->get('entity_type.manager'),
+ $container->get('renderer')
+ );
+ }
+
+ /**
+ * Builds the render array for the given media entity in the given langcode.
+ *
+ * @param \Drupal\media\MediaInterface $media
+ * A media entity for which to render the 'embed' view mode.
+ * @param string $view_mode
+ * The view mode to render it in.
+ * @param string $langcode
+ * Language code in which the media entity should be rendered.
+ *
+ * @return array
+ * A render array.
+ */
+ protected function renderMedia(MediaInterface $media, $view_mode, $langcode) {
+ $build = $this->entityTypeManager
+ ->getViewBuilder('media')
+ ->view($media, $view_mode, $langcode);
+
+ // There are a few concerns when rendering an embedded media entity:
+ // - entity access checking happens not during rendering but during routing,
+ // and therefore we have to do it explicitly here for the embedded entity;
+ $build['#access'] = $media->access('view', NULL, TRUE);
+ // - caching an embedded media entity separately is unnecessary; the host
+ // entity is already render cached;
+ unset($build['#cache']['keys']);
+ // - Contextual Links do not make sense for embedded entities; we only allow
+ // the host entity to be contextually managed;
+ $build['#pre_render'][] = static::class . '::disableContextualLinks';
+ // - Quick Edit does not make sense for embedded entities; we only allow the
+ // host entity to be edited in-place.
+ $build['#pre_render'][] = static::class . '::disableQuickEdit';
+ // - default styling may break captioned media embeds; attach asset library
+ // to ensure captions behave as intended.
+ $build[':media_embed']['#attached']['library'][] = 'media/filter.caption';
+
+ return $build;
+ }
+
+ /**
+ * Builds the render array for a missing media entity.
+ *
+ * @return array
+ * A render array.
+ */
+ protected function renderMissingMedia() {
+ return [
+ '#type' => 'html_tag',
+ '#tag' => 'img',
+ '#attributes' => [
+ 'src' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')),
+ 'width' => 180,
+ 'height' => 180,
+ 'alt' => $this->t('Missing media.'),
+ 'title' => $this->t('Missing media.'),
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process($text, $langcode) {
+ $result = new FilterProcessResult($text);
+
+ if (stristr($text, 'drupal-media') !== FALSE) {
+ $dom = Html::load($text);
+ $xpath = new \DOMXPath($dom);
+
+ foreach ($xpath->query('//drupal-media[@data-entity-type="media" and normalize-space(@data-view-mode)!="" and normalize-space(@data-entity-uuid)!=""]') as $node) {
+ $uuid = $node->getAttribute('data-entity-uuid');
+ $view_mode_id = $node->getAttribute('data-view-mode');
+
+ // Delete the attributes this filter reacts to.
+ $node->removeAttribute('data-entity-type');
+ $node->removeAttribute('data-entity-uuid');
+ $node->removeAttribute('data-view-mode');
+
+ $media = $this->entityRepository->loadEntityByUuid('media', $uuid);
+ assert($media === NULL || $media instanceof MediaInterface);
+ if (!$media) {
+ \Drupal::logger('media')->error('The media item with UUID "@uuid" does not exist.', ['@uuid' => $uuid]);
+ }
+
+ $view_mode = $this->entityRepository->loadEntityByConfigTarget('entity_view_mode', "media.$view_mode_id");
+ if (!$view_mode) {
+ \Drupal::logger('media')->error('The view mode "@view-mode-id" does not exist.', ['@view-mode-id' => $view_mode_id]);
+ }
+
+ // @todo recursive embedding protection
+
+ $build = $media && $view_mode
+ ? $this->renderMedia($media, $view_mode_id, $langcode)
+ : $this->renderMissingMedia();
+
+ // Any attributes not consumed by the filter should be persisted onto
+ // the rendered embedded entity. For example, `data-align` and
+ // `data-caption` should persist, so that even when embedded media goes
+ // missing, at least the caption and visual structure won't get lost.
+ foreach ($node->attributes as $attribute) {
+ $build['#attributes'][$attribute->nodeName] = $attribute->nodeValue;
+ }
+
+ $this->renderIntoDomNode($build, $node, $result);
+ }
+
+ $result->setProcessedText(Html::serialize($dom));
+ }
+
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function tips($long = FALSE) {
+ if ($long) {
+ return $this->t('
+
You can embed media items:
+
+ - Choose which media item to embed:
<drupal-media data-entity-uuid="07bf3a2e-1941-4a44-9b02-2d1d7a41ec0e" />
+ - Choose a view:
data-view-mode="teaser"
.
+ - The
data-entity-type="media"
attribute is required for consistency.
+
');
+ }
+ else {
+ return $this->t('You can embed media items (using the <drupal-media>
tag).');
+ }
+ }
+
+ /**
+ * Renders the given render array into the given DOM node.
+ *
+ * @param array $build
+ * The render array to render in isolation
+ * @param \DOMNode &$node
+ * The DOM node to render into.
+ * @param \Drupal\filter\FilterProcessResult $result
+ * The accumulated result of filter processing, updated with the metadata
+ * bubbled during rendering.
+ */
+ protected function renderIntoDomNode(array $build, \DOMNode $node, FilterProcessResult &$result) {
+ // We need to render the embedded entity:
+ // - without replacing placeholders, so that the placeholders are
+ // only replaced at the last possible moment. Hence we cannot use
+ // either renderPlain() or renderRoot(), so we must use render().
+ // - without bubbling beyond this filter, because filters must
+ // ensure that the bubbleable metadata for the changes they make
+ // when filtering text makes it onto the FilterProcessResult
+ // object that they return ($result). To prevent that bubbling, we
+ // must wrap the call to render() in a render context.
+ $markup = $this->renderer->executeInRenderContext(new RenderContext(), function () use (&$build) {
+ return $this->renderer->render($build);
+ });
+ $result = $result->merge(BubbleableMetadata::createFromRenderArray($build));
+ static::replaceNodeContent($node, $markup);
+ }
+
+ /**
+ * Replaces the contents of a DOMNode.
+ *
+ * @param \DOMNode $node
+ * A DOMNode object.
+ * @param string $content
+ * The text or HTML that will replace the contents of $node.
+ */
+ protected static function replaceNodeContent(\DOMNode &$node, $content) {
+ if (strlen($content)) {
+ // Load the content into a new DOMDocument and retrieve the DOM nodes.
+ $replacement_nodes = Html::load($content)->getElementsByTagName('body')
+ ->item(0)
+ ->childNodes;
+ }
+ else {
+ $replacement_nodes = [$node->ownerDocument->createTextNode('')];
+ }
+
+ foreach ($replacement_nodes as $replacement_node) {
+ // Import the replacement node from the new DOMDocument into the original
+ // one, importing also the child nodes of the replacement node.
+ $replacement_node = $node->ownerDocument->importNode($replacement_node, TRUE);
+ $node->parentNode->insertBefore($replacement_node, $node);
+ }
+ $node->parentNode->removeChild($node);
+ }
+
+ /**
+ * Disables Contextual Links for the embedded media by removing its property.
+ *
+ * @param array $build
+ * The render array for the embedded media.
+ *
+ * @return array
+ * The updated render array.
+ *
+ * @see \Drupal\Core\Entity\EntityViewBuilder::addContextualLinks()
+ */
+ public static function disableContextualLinks(array $build) {
+ unset($build['#contextual_links']);
+ return $build;
+ }
+
+ /**
+ * Disables Quick Edit for the embedded media by removing its attributes.
+ *
+ * @param array $build
+ * The render array for the embedded media.
+ *
+ * @return array
+ * The updated render array.
+ *
+ * @see quickedit_entity_view_alter()
+ */
+ public static function disableQuickEdit(array $build) {
+ unset($build['#attributes']['data-quickedit-entity-id']);
+ return $build;
+ }
+
+}
diff --git a/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml b/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml
new file mode 100644
index 0000000000..5c4c7838d6
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml
@@ -0,0 +1,8 @@
+name: Media Filter test
+description: 'Provides functionality to test the Media Embed filter.'
+type: module
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+ - drupal:media
diff --git a/core/modules/media/tests/modules/media_test_filter/media_test_filter.module b/core/modules/media/tests/modules/media_test_filter/media_test_filter.module
new file mode 100644
index 0000000000..b68c58b5b5
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_filter/media_test_filter.module
@@ -0,0 +1,25 @@
+addCacheTags(['_media_test_filter_access:' . $entity->getEntityTypeId() . ':' . $entity->id()]);
+}
+
+/**
+ * Implements hook_entity_view_alter().
+ */
+function media_test_filter_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
+ $build['#attributes']['data-media-embed-test-view-mode'] = $display->getMode();
+}
diff --git a/core/modules/media/tests/src/Functional/FilterTest.php b/core/modules/media/tests/src/Functional/FilterTest.php
new file mode 100644
index 0000000000..81aa3b40dc
--- /dev/null
+++ b/core/modules/media/tests/src/Functional/FilterTest.php
@@ -0,0 +1,148 @@
+drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
+ FilterFormat::create([
+ 'format' => 'custom_format',
+ 'name' => 'Custom format',
+ 'filters' => [
+ 'media_embed' => [
+ 'status' => 1,
+ ],
+ ],
+ ])->save();
+
+ // Create a user with required permissions.
+ $this->webUser = $this->drupalCreateUser([
+ 'access content',
+ 'view media',
+ 'create page content',
+ 'use text format custom_format',
+ 'access in-place editing',
+ 'access contextual links',
+ ]);
+ $this->drupalLogin($this->webUser);
+
+ file_put_contents('public://llama.jpg', str_repeat('t', 10));
+ $file = File::create([
+ 'uri' => 'public://llama.jpg',
+ 'filename' => 'llama.jpg',
+ ]);
+ $file->save();
+
+ $media_type = $this->createMediaType('image');
+
+ EntityViewMode::create([
+ 'id' => 'media.foobar',
+ 'targetEntityType' => 'media',
+ 'status' => TRUE,
+ 'enabled' => TRUE,
+ 'label' => $this->randomMachineName(),
+ ])->save();
+
+ EntityViewDisplay::create([
+ 'targetEntityType' => 'media',
+ 'bundle' => $media_type->id(),
+ 'mode' => 'foobar',
+ 'status' => TRUE,
+ ])->removeComponent('thumbnail')
+ ->removeComponent('created')
+ ->removeComponent('uid')
+ ->setComponent('field_media_image', [
+ 'label' => 'visually_hidden',
+ 'type' => 'image',
+ 'settings' => [
+ 'image_style' => 'medium',
+ 'image_link' => 'file',
+ ],
+ 'third_party_settings' => [],
+ 'weight' => 1,
+ 'region' => 'content',
+ ])
+ ->save();
+
+ $this->media = Media::create([
+ 'bundle' => $media_type->id(),
+ 'field_media_image' => [
+ 0 => [
+ 'target_id' => $file->Id(),
+ 'alt' => 'So hipster.',
+ ],
+ ],
+ ]);
+ $this->media->save();
+ }
+
+ /**
+ * Tests the media_embed filter.
+ *
+ * Ensures that entities are getting rendered when correct data attributes
+ * are passed. Also tests situations when embed fails.
+ */
+ public function testFilter() {
+ $content = 'This placeholder should not be rendered.';
+ $settings = [];
+ $settings['type'] = 'page';
+ $settings['title'] = 'Test entity embed with entity-id and view-mode';
+ $settings['body'] = [['value' => $content, 'format' => 'custom_format']];
+ $node = $this->drupalCreateNode($settings);
+ $this->drupalGet('node/' . $node->id());
+ // Embedded media rendered.
+ $this->assertSession()->responseNotContains('assertSession()->responseContains('llama.jpg');
+ $this->assertSession()->responseContains('files/styles/medium/public/llama.jpg');
+ // Quick Edit enabled for the host entity, disabled for the embedded entity.
+ $this->assertSession()->responseContains('data-quickedit-entity-id="node/1"');
+ $this->assertSession()->responseNotContains('data-quickedit-entity-id="media');
+ // Contextual Links enabled for the host entity, disabled for the embedded
+ // entity.
+ $this->assertSession()->responseContains('contextual-region node');
+ $this->assertSession()->responseNotContains('contextual-region media');
+
+ // @todo Improve \Drupal\Tests\entity_embed\Functional\EntityEmbedFilterTest so that we don't need to change as much here. Also, specifically add test coverage for:
+ // - missing view mode
+ // - existing view mode but missing corresponding view display
+ // - embedded media is not render cached
+ }
+
+}
diff --git a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php
new file mode 100644
index 0000000000..3293ec9a6b
--- /dev/null
+++ b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php
@@ -0,0 +1,358 @@
+createEmbedCode($embed_attributes);
+
+ $result = $this->applyFilter($content);
+
+ $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="' . $expected_view_mode . '"]'));
+ $this->assertHasAttributes($this->cssSelect('div[data-media-embed-test-view-mode="' . $expected_view_mode . '"]')[0], $expected_attributes);
+ $this->assertSame($expected_cacheability->getCacheTags(), $result->getCacheTags());
+ $this->assertSame($expected_cacheability->getCacheContexts(), $result->getCacheContexts());
+ $this->assertSame($expected_cacheability->getCacheMaxAge(), $result->getCacheMaxAge());
+ $this->assertSame(['library'], array_keys($result->getAttachments()));
+ $this->assertSame(['media/filter.caption'], $result->getAttachments()['library']);
+ }
+
+ /**
+ * Data provider for testBasics().
+ */
+ public function providerTestBasics() {
+ $expected_cacheability_full = (new CacheableMetadata())
+ ->setCacheTags([
+ '_media_test_filter_access:media:1',
+ '_media_test_filter_access:user:2',
+ 'config:image.style.thumbnail',
+ 'file:1',
+ 'media:1',
+ 'media_view',
+ 'user:2',
+ ])
+ ->setCacheContexts(['timezone', 'user.permissions'])
+ ->setCacheMaxAge(Cache::PERMANENT);
+
+ return [
+ 'data-entity-uuid + data-view-mode=full' => [
+ [
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID,
+ 'data-view-mode' => 'full',
+ ],
+ 'full',
+ [],
+ $expected_cacheability_full,
+ ],
+ 'data-entity-uuid + data-view-mode=foobar' => [
+ [
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID,
+ 'data-view-mode' => 'foobar',
+ ],
+ 'foobar',
+ [],
+ (new CacheableMetadata())
+ ->setCacheTags([
+ '_media_test_filter_access:media:1',
+ 'config:image.style.medium',
+ 'file:1',
+ 'media:1',
+ 'media_view',
+ ])
+ ->setCacheContexts(['url.site', 'user.permissions'])
+ ->setCacheMaxAge(Cache::PERMANENT),
+ ],
+ 'custom attributes are retained' => [
+ [
+ 'data-foo' => 'bar',
+ 'foo' => 'bar',
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID,
+ 'data-view-mode' => 'full',
+ ],
+ 'full',
+ [
+ 'data-foo' => 'bar',
+ 'foo' => 'bar',
+ ],
+ $expected_cacheability_full,
+ ],
+ ];
+ }
+
+ /**
+ * Tests that entity access is respected by embedding an unpublished entity.
+ *
+ * @dataProvider providerAccessUnpublished
+ */
+ public function testAccessUnpublished($allowed_to_view_unpublished, $expected_rendered, CacheableMetadata $expected_cacheability, array $expected_attachments) {
+ // Unpublish the embedded entity so we can test variations in behavior.
+ $this->embeddedEntity->setUnpublished()->save();
+
+ // Are we testing as a user who is allowed to view the embedded entity?
+ if ($allowed_to_view_unpublished) {
+ $this->container->get('current_user')
+ ->addRole($this->drupalCreateRole(['view own unpublished media']));
+ }
+
+ $content = $this->createEmbedCode([
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID,
+ 'data-view-mode' => 'full',
+ ]);
+ $result = $this->applyFilter($content);
+
+ if (!$expected_rendered) {
+ $this->assertEmpty($this->getRawContent());
+ }
+ else {
+ $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="full"]'));
+ }
+
+ // Expected bubbleable metadata.
+ $this->assertSame($expected_cacheability->getCacheTags(), $result->getCacheTags());
+ $this->assertSame($expected_cacheability->getCacheContexts(), $result->getCacheContexts());
+ $this->assertSame($expected_cacheability->getCacheMaxAge(), $result->getCacheMaxAge());
+ $this->assertSame($expected_attachments, $result->getAttachments());
+ }
+
+ /**
+ * Data provider for testAccessUnpublished().
+ */
+ public function providerAccessUnpublished() {
+ return [
+ 'user cannot access embedded media media' => [
+ FALSE,
+ FALSE,
+ (new CacheableMetadata())
+ ->setCacheTags([
+ '_media_test_filter_access:media:1',
+ 'media:1',
+ 'media_view',
+ ])
+ ->setCacheContexts(['user.permissions'])
+ ->setCacheMaxAge(Cache::PERMANENT),
+ [],
+ ],
+ 'user can access embedded media' => [
+ TRUE,
+ TRUE,
+ (new CacheableMetadata())
+ ->setCacheTags([
+ '_media_test_filter_access:media:1',
+ '_media_test_filter_access:user:2',
+ 'config:image.style.thumbnail',
+ 'file:1',
+ 'media:1',
+ 'media_view',
+ 'user:2',
+ ])
+ ->setCacheContexts(['timezone', 'user', 'user.permissions'])
+ ->setCacheMaxAge(Cache::PERMANENT),
+ ['library' => ['media/filter.caption']],
+ ],
+ ];
+ }
+
+ /**
+ * Tests the indicator for missing entities.
+ *
+ * @dataProvider providerMissingEntityIndicator
+ */
+ public function testMissingEntityIndicator($uuid) {
+ $content = $this->createEmbedCode([
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => $uuid,
+ 'data-view-mode' => 'foobar',
+ ]);
+
+ // If the UUID being used in the embed is that of the sample entity, first
+ // assert that it currently results in a functional embed, then delete it.
+ if ($uuid === static::EMBEDDED_ENTITY_UUID) {
+ $this->applyFilter($content);
+ $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="foobar"]'));
+ $this->embeddedEntity->delete();
+ }
+
+ $this->applyFilter($content);
+ $this->assertCount(0, $this->cssSelect('div[data-media-embed-test-view-mode="foobar"]'));
+ $deleted_embed_warning = $this->cssSelect('img')[0];
+ $this->assertNotEmpty($deleted_embed_warning);
+ $this->assertHasAttributes($deleted_embed_warning, [
+ 'alt' => 'Missing media.',
+ 'src' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')),
+ 'title' => 'Missing media.',
+ ]);
+ }
+
+ /**
+ * Data provider for testMissingEntityIndicator().
+ */
+ public function providerMissingEntityIndicator() {
+ return [
+ 'valid UUID but for a deleted entity' => [
+ static::EMBEDDED_ENTITY_UUID,
+ ],
+ 'node; invalid UUID' => [
+ 'invalidUUID',
+ ],
+ ];
+ }
+
+ /**
+ * Tests that only tags are processed.
+ */
+ public function testOnlyDrupalMediaTagProcessed() {
+ $content = $this->createEmbedCode([
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => $this->embeddedEntity->uuid(),
+ 'data-view-mode' => 'full',
+ ]);
+ $content = str_replace('drupal-media', 'drupal-entity', $content);
+
+ $filter_result = $this->processText($content, 'en', ['media_embed']);
+ // If input equals output, the filter didn't change anything.
+ $this->assertSame($content, $filter_result->getProcessedText());
+ }
+
+ /**
+ * @covers \Drupal\filter\Plugin\Filter\FilterAlign
+ * @covers \Drupal\filter\Plugin\Filter\FilterCaption
+ * @dataProvider providerFilterIntegration
+ */
+ public function testFilterIntegration(array $filter_ids, array $additional_attributes, $verification_selector, $expected_verification_success, array $expected_asset_libraries, $prefix = '', $suffix = '') {
+ $content = $this->createEmbedCode([
+ 'data-entity-type' => 'media',
+ 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID,
+ 'data-view-mode' => 'full',
+ ] + $additional_attributes);
+ $content = $prefix . $content . $suffix;
+
+ $result = $this->processText($content, 'en', $filter_ids);
+ $this->setRawContent($result->getProcessedText());
+ $this->assertCount($expected_verification_success ? 1 : 0, $this->cssSelect($verification_selector));
+ $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="full"]'));
+ $this->assertSame([
+ '_media_test_filter_access:media:1',
+ '_media_test_filter_access:user:2',
+ 'config:image.style.thumbnail',
+ 'file:1',
+ 'media:1',
+ 'media_view',
+ 'user:2',
+ ], $result->getCacheTags());
+ $this->assertSame(['timezone', 'user.permissions'], $result->getCacheContexts());
+ $this->assertSame(Cache::PERMANENT, $result->getCacheMaxAge());
+ $this->assertSame(['library'], array_keys($result->getAttachments()));
+ $this->assertSame($expected_asset_libraries, $result->getAttachments()['library']);
+ }
+
+ /**
+ * Data provider for testFilterIntegration().
+ */
+ public function providerFilterIntegration() {
+ $default_asset_libraries = ['media/filter.caption'];
+
+ $caption_additional_attributes = ['data-caption' => 'Yo.'];
+ $caption_verification_selector = 'figure > figcaption';
+ $caption_test_cases = [
+ '`data-caption`; only `media_embed` ⇒ caption absent' => [
+ ['media_embed'],
+ $caption_additional_attributes,
+ $caption_verification_selector,
+ FALSE,
+ $default_asset_libraries,
+ ],
+ '`data-caption`; `filter_caption` + `media_embed` ⇒ caption present' => [
+ ['filter_caption', 'media_embed'],
+ $caption_additional_attributes,
+ $caption_verification_selector,
+ TRUE,
+ ['filter/caption', 'media/filter.caption'],
+ ],
+ '`` + `data-caption`; `filter_caption` + `media_embed` ⇒ caption present, link preserved' => [
+ ['filter_caption', 'media_embed'],
+ $caption_additional_attributes,
+ 'figure > a[href="https://www.drupal.org"] + figcaption',
+ TRUE,
+ ['filter/caption', 'media/filter.caption'],
+ '',
+ '',
+ ],
+ ];
+
+ $align_additional_attributes = ['data-align' => 'center'];
+ $align_verification_selector = 'div[data-media-embed-test-view-mode].align-center';
+ $align_test_cases = [
+ '`data-align`; `media_embed` ⇒ alignment absent' => [
+ ['media_embed'],
+ $align_additional_attributes,
+ $align_verification_selector,
+ FALSE,
+ $default_asset_libraries,
+ ],
+ '`data-align`; `filter_align` + `media_embed` ⇒ alignment present' => [
+ ['filter_align', 'media_embed'],
+ $align_additional_attributes,
+ $align_verification_selector,
+ TRUE,
+ $default_asset_libraries,
+ ],
+ '`` + `data-align`; `filter_align` + `media_embed` ⇒ alignment present, link preserved' => [
+ ['filter_align', 'media_embed'],
+ $align_additional_attributes,
+ 'a[href="https://www.drupal.org"] > div[data-media-embed-test-view-mode].align-center',
+ TRUE,
+ $default_asset_libraries,
+ '',
+ '',
+ ],
+ ];
+
+ $caption_and_align_test_cases = [
+ '`data-caption` + `data-align`; `filter_align` + `filter_caption` + `media_embed` ⇒ aligned caption present' => [
+ ['filter_align', 'filter_caption', 'media_embed'],
+ $align_additional_attributes + $caption_additional_attributes,
+ 'figure.align-center > figcaption',
+ TRUE,
+ ['filter/caption', 'media/filter.caption'],
+ ],
+ '`` + `data-caption` + `data-align`; `filter_align` + `filter_caption` + `media_embed` ⇒ aligned caption present, link preserved' => [
+ ['filter_align', 'filter_caption', 'media_embed'],
+ $align_additional_attributes + $caption_additional_attributes,
+ 'figure.align-center > a[href="https://www.drupal.org"] + figcaption',
+ TRUE,
+ ['filter/caption', 'media/filter.caption'],
+ '',
+ '',
+ ],
+ ];
+
+ return $caption_test_cases + $align_test_cases + $caption_and_align_test_cases;
+ }
+
+}
diff --git a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTestBase.php b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTestBase.php
new file mode 100644
index 0000000000..79ceb238d8
--- /dev/null
+++ b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTestBase.php
@@ -0,0 +1,249 @@
+installSchema('file', ['file_usage']);
+ $this->installSchema('system', 'sequences');
+ $this->installEntitySchema('file');
+ $this->installEntitySchema('media');
+ $this->installEntitySchema('user');
+ $this->installConfig('filter');
+ $this->installConfig('image');
+ $this->installConfig('media');
+ $this->installConfig('system');
+
+ // Create a user with required permissions. Ensure that we don't use user 1
+ // because that user is treated in special ways by access control handlers.
+ $admin_user = $this->drupalCreateUser([]);
+ $user = $this->drupalCreateUser([
+ 'access content',
+ 'view media',
+ ]);
+ $this->container->set('current_user', $user);
+
+ $this->image = File::create([
+ 'uri' => $this->getTestFiles('image')[0]->uri,
+ 'uid' => 2,
+ ]);
+ $this->image->setPermanent();
+ $this->image->save();
+
+ // Create a sample media entity to be embedded.
+ $media_type = $this->createMediaType('image', ['id' => 'image']);
+ EntityViewMode::create([
+ 'id' => 'media.foobar',
+ 'targetEntityType' => 'media',
+ 'status' => TRUE,
+ 'enabled' => TRUE,
+ 'label' => $this->randomMachineName(),
+ ])->save();
+ EntityViewDisplay::create([
+ 'targetEntityType' => 'media',
+ 'bundle' => $media_type->id(),
+ 'mode' => 'foobar',
+ 'status' => TRUE,
+ ])->removeComponent('thumbnail')
+ ->removeComponent('created')
+ ->removeComponent('uid')
+ ->setComponent('field_media_image', [
+ 'label' => 'visually_hidden',
+ 'type' => 'image',
+ 'settings' => [
+ 'image_style' => 'medium',
+ 'image_link' => 'file',
+ ],
+ 'third_party_settings' => [],
+ 'weight' => 1,
+ 'region' => 'content',
+ ])
+ ->save();
+ $media = Media::create([
+ 'uuid' => static::EMBEDDED_ENTITY_UUID,
+ 'bundle' => 'image',
+ 'name' => 'Screaming hairy armadillo',
+ 'field_media_image' => [
+ [
+ 'target_id' => $this->image->id(),
+ 'alt' => 'default alt',
+ 'title' => 'default title',
+ ],
+ ],
+ ])->setOwner($user);
+ $media->save();
+ $this->embeddedEntity = $media;
+ }
+
+ /**
+ * Gets an embed code with given attributes.
+ *
+ * @param array $attributes
+ * The attributes to add.
+ *
+ * @return string
+ * A string containing a drupal-entity dom element.
+ *
+ * @see assertEntityEmbedFilterHasRun()
+ */
+ protected function createEmbedCode(array $attributes) {
+ $dom = Html::load('This placeholder should not be rendered.');
+ $xpath = new \DOMXPath($dom);
+ $drupal_entity = $xpath->query('//drupal-media')[0];
+ foreach ($attributes as $attribute => $value) {
+ $drupal_entity->setAttribute($attribute, $value);
+ }
+ return Html::serialize($dom);
+ }
+
+ /**
+ * Applies the `@Filter=media_embed` filter to text, pipes to raw content.
+ *
+ * @param string $text
+ * The text string to be filtered.
+ * @param string $langcode
+ * The language code of the text to be filtered.
+ *
+ * @return \Drupal\filter\FilterProcessResult
+ * The filtered text, wrapped in a FilterProcessResult object, and possibly
+ * with associated assets, cacheability metadata and placeholders.
+ *
+ * @see \Drupal\Tests\entity_embed\Kernel\EntityEmbedFilterTestBase::createEmbedCode()
+ * @see \Drupal\KernelTests\AssertContentTrait::setRawContent()
+ */
+ protected function applyFilter($text, $langcode = 'en') {
+ $this->assertContains('assertContains('This placeholder should not be rendered.', $text);
+ $filter_result = $this->processText($text, $langcode);
+ $output = $filter_result->getProcessedText();
+ $this->assertNotContains('assertNotContains('This placeholder should not be rendered.', $output);
+ $this->setRawContent($output);
+ return $filter_result;
+ }
+
+ /**
+ * Assert that the SimpleXMLElement object has the given attributes.
+ *
+ * @param \SimpleXMLElement $element
+ * The SimpleXMLElement object to check.
+ * @param array $attributes
+ * An array of attributes.
+ */
+ protected function assertHasAttributes(\SimpleXMLElement $element, array $attributes) {
+ foreach ($attributes as $attribute => $value) {
+ $this->assertSame((string) $value, (string) $element[$attribute]);
+ }
+ }
+
+ /**
+ * Processes text through the provided filters.
+ *
+ * @param string $text
+ * The text string to be filtered.
+ * @param string $langcode
+ * The language code of the text to be filtered.
+ * @param string[] $filter_ids
+ * (optional) The filter plugin IDs to apply to the given text, in the order
+ * they are being requested to be executed.
+ *
+ * @return \Drupal\filter\FilterProcessResult
+ * The filtered text, wrapped in a FilterProcessResult object, and possibly
+ * with associated assets, cacheability metadata and placeholders.
+ *
+ * @see \Drupal\filter\Element\ProcessedText::preRenderText()
+ */
+ protected function processText($text, $langcode = 'und', array $filter_ids = ['media_embed']) {
+ $manager = $this->container->get('plugin.manager.filter');
+ $bag = new FilterPluginCollection($manager, []);
+ $filters = [];
+ foreach ($filter_ids as $filter_id) {
+ $filters[] = $bag->get($filter_id);
+ }
+
+ $render_context = new RenderContext();
+ /** @var \Drupal\filter\FilterProcessResult $filter_result */
+ $filter_result = $this->container->get('renderer')->executeInRenderContext($render_context, function () use ($text, $filters, $langcode) {
+ $metadata = new BubbleableMetadata();
+ foreach ($filters as $filter) {
+ /** @var \Drupal\filter\FilterProcessResult $result */
+ $result = $filter->process($text, $langcode);
+ $metadata = $metadata->merge($result);
+ $text = $result->getProcessedText();
+ }
+ return (new FilterProcessResult($text))->merge($metadata);
+ });
+ if (!$render_context->isEmpty()) {
+ $filter_result = $filter_result->merge($render_context->pop());
+ }
+ return $filter_result;
+ }
+
+}
diff --git a/core/themes/stable/css/media/filter.caption.css b/core/themes/stable/css/media/filter.caption.css
new file mode 100644
index 0000000000..a92505c308
--- /dev/null
+++ b/core/themes/stable/css/media/filter.caption.css
@@ -0,0 +1,10 @@
+/**
+ * @file
+ * Caption filter: default styling for displaying Media Embed captions.
+ */
+
+.caption .media .field,
+.caption .media .field * {
+ float: none;
+ margin: unset;
+}
diff --git a/core/themes/stable/stable.info.yml b/core/themes/stable/stable.info.yml
index ea26e95227..91deed83a2 100644
--- a/core/themes/stable/stable.info.yml
+++ b/core/themes/stable/stable.info.yml
@@ -144,6 +144,11 @@ libraries-override:
component:
css/locale.admin.css: css/locale/locale.admin.css
+ media/filter.caption:
+ css:
+ component:
+ css/filter.caption.css: css/media/filter.caption.css
+
media/oembed.formatter:
css:
component: