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:

+ '); + } + 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: