diff --git a/metatag.module b/metatag.module index e2d32d8..dc563bd 100644 --- a/metatag.module +++ b/metatag.module @@ -13,6 +13,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; @@ -614,13 +615,14 @@ function metatag_entity_base_field_info(EntityTypeInterface $entity_type) { // If the entity type doesn't have a base table, has no link template then // there's no point in supporting it. if (!empty($base_table) && $canonical_template_exists && !in_array($original_class, $classes_to_skip)) { - $fields['metatag'] = BaseFieldDefinition::create('map') + $fields['metatag'] = BaseFieldDefinition::create('metatag_computed') ->setLabel(t('Metatags (Hidden field for JSON support)')) - ->setDescription(t('The meta tags for the entity.')) - ->setClass('\Drupal\metatag\Plugin\Field\MetatagEntityFieldItemList') + ->setDescription(t('The computed meta tags for the entity.')) ->setComputed(TRUE) ->setTranslatable(TRUE) - ->setTargetEntityTypeId($entity_type->id()); + ->setReadOnly(TRUE) + ->setTargetEntityTypeId($entity_type->id()) + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); } return $fields; diff --git a/src/Normalizer/MetatagHalNormalizer.php b/src/Normalizer/MetatagHalNormalizer.php deleted file mode 100644 index 4d0aff9..0000000 --- a/src/Normalizer/MetatagHalNormalizer.php +++ /dev/null @@ -1,27 +0,0 @@ - [$normalized], - ]; - } - -} diff --git a/src/Normalizer/MetatagNormalizer.php b/src/Normalizer/MetatagNormalizer.php deleted file mode 100644 index 32837dc..0000000 --- a/src/Normalizer/MetatagNormalizer.php +++ /dev/null @@ -1,53 +0,0 @@ -getEntity(); - - $tags = metatag_get_tags_from_route($entity); - - $normalized['value'] = []; - if (isset($tags['#attached']['html_head'])) { - foreach ($tags['#attached']['html_head'] as $tag) { - // @todo Work out a proper, long-term fix for this. - if (isset($tag[0]['#attributes']['content'])) { - $normalized['value'][$tag[1]] = $tag[0]['#attributes']['content']; - } - elseif (isset($tag[0]['#attributes']['href'])) { - $normalized['value'][$tag[1]] = $tag[0]['#attributes']['href']; - } - } - } - - if (isset($context['langcode'])) { - $normalized['lang'] = $context['langcode']; - } - - return $normalized; - } - - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, $type, $format = NULL) { - return FALSE; - } - -} diff --git a/src/Plugin/Field/FieldType/ComputedMetatagsFieldItem.php b/src/Plugin/Field/FieldType/ComputedMetatagsFieldItem.php new file mode 100644 index 0000000..0029a3b --- /dev/null +++ b/src/Plugin/Field/FieldType/ComputedMetatagsFieldItem.php @@ -0,0 +1,51 @@ +setLabel(t('Tag')) + ->setRequired(TRUE); + $properties['attributes'] = MapDataDefinition::create() + ->setLabel(t('Name')) + ->setRequired(TRUE); + return $properties; + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + $value = $this->get('attributes')->getValue(); + return $value === NULL || $value === []; + } + +} diff --git a/src/Plugin/Field/MetatagEntityFieldItemList.php b/src/Plugin/Field/MetatagEntityFieldItemList.php index bdc6a3e..4a07046 100644 --- a/src/Plugin/Field/MetatagEntityFieldItemList.php +++ b/src/Plugin/Field/MetatagEntityFieldItemList.php @@ -3,7 +3,10 @@ namespace Drupal\metatag\Plugin\Field; use Drupal\Core\Field\FieldItemList; -use Drupal\Core\TypedData\ComputedItemListTrait; +use Drupal\Core\Render\RenderContext; +use Drupal\Core\Render\RendererInterface; +use Drupal\metatag\MetatagManagerInterface; +use Drupal\metatag\TypedData\ComputedItemListTrait; /** * Defines a metatag list class for better normalization targeting. @@ -12,14 +15,54 @@ class MetatagEntityFieldItemList extends FieldItemList { use ComputedItemListTrait; + /** + * Whether the metatags have been generated. + * + * This allows the cached value to be recomputed after the entity is saved. + * + * @var bool + */ + protected $metatagsGenerated = FALSE; + + /** + * {@inheritdoc} + */ + protected function valueNeedsRecomputing() { + return !$this->getEntity()->isNew() && !$this->metatagsGenerated; + } + /** * {@inheritdoc} */ protected function computeValue() { - // This field does not really compute anything, it is solely used as a base - // for normalizers. - // @see \Drupal\metatag\Normalizer\MetatagNormalizer - return NULL; + $entity = $this->getEntity(); + if ($entity->isNew()) { + return; + } + $renderer = \Drupal::service('renderer'); + assert($renderer instanceof RendererInterface); + + // @todo capture the cacheable metadata and properly bubble it up. + // @see https://www.drupal.org/project/metatag/issues/3175269 + // @see https://www.drupal.org/project/metatag/issues/3039650 + $tags = $renderer->executeInRenderContext(new RenderContext(), static function () use ($entity) { + $metatag_manager = \Drupal::service('metatag.manager'); + assert($metatag_manager instanceof MetatagManagerInterface); + + // Generate all of the meta tags for this entity. + $metatags_for_entity = $metatag_manager->tagsFromEntityWithDefaults($entity); + return $metatag_manager->generateRawElements($metatags_for_entity, $entity); + }); + $this->list = []; + foreach ($tags as $tag) { + $offset = count($this->list); + $this->list[] = $this->createItem($offset, [ + 'tag' => $tag['#tag'], + 'attributes' => $tag['#attributes'], + ]); + } + + $this->metatagsGenerated = TRUE; } } diff --git a/src/TypedData/ComputedItemListTrait.php b/src/TypedData/ComputedItemListTrait.php new file mode 100644 index 0000000..21cf66f --- /dev/null +++ b/src/TypedData/ComputedItemListTrait.php @@ -0,0 +1,46 @@ +valueComputed === FALSE || $this->valueNeedsRecomputing()) { + $this->computeValue(); + $this->valueComputed = TRUE; + } + } + + /** + * Returns whether the value should be recomputed. + * + * This is run after the value has been computed at least once. + * + * @return bool + */ + abstract protected function valueNeedsRecomputing(); + +} diff --git a/tests/src/Functional/EntityTestMetatagTest.php b/tests/src/Functional/EntityTestMetatagTest.php new file mode 100644 index 0000000..d1f2846 --- /dev/null +++ b/tests/src/Functional/EntityTestMetatagTest.php @@ -0,0 +1,167 @@ +container->get('config.factory'); + $config_factory + ->getEditable('metatag.metatag_defaults.global') + ->set('tags.title', 'Global Title') + ->set('tags.description', 'Global description') + ->set('tags.keywords', 'drupal8, testing, jsonapi, metatag') + ->save(); + + // The global default canonical URL is [current-page:url] which returns the + // endpoint URL on a REST request, so be sure to set a default canonical URL + // for the entity_test entity type. + MetatagDefaults::create([ + 'id' => 'entity_test', + 'tags' => [ + 'title' => '[entity_test:name] | [site:name]', + 'canonical_url' => '[entity_test:url]', + ], + ])->save(); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Add the fields here rather than in ::setUp() because they need to be + // created before the entity is, and this method is called from + // parent::setUp(). + if (!$this->addedFields) { + $this->addedFields = TRUE; + + FieldStorageConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_metatag', + 'type' => 'metatag', + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_metatag', + 'bundle' => 'entity_test', + ])->save(); + } + + $entity_test = EntityTest::create([ + 'name' => 'Llama', + 'type' => 'entity_test', + 'field_metatag' => [ + 'value' => [ + 'description' => 'This is a description for use in Search Engines' + ], + ], + ]); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $metatags = [ + 'description' => 'This is a description for use in Search Engines', + ]; + // When bc_primitives_as_strings is 1 the field of type metatag is + // normalized by \Drupal\serialization\Normalizer\TypedDataNormalizer which + // just outputs the serialized string. Otherwise it will use + // \Drupal\serialization\Normalizer\PrimitiveDataNormalizer which + // unserializes the values on normalization. + if ($this->config('serialization.settings')->get('bc_primitives_as_strings')) { + $metatags = serialize($metatags); + } + $canonical_url = base_path() . 'entity_test/' . $this->entity->id(); + return parent::getExpectedNormalizedEntity() + [ + 'field_metatag' => [ + [ + 'value' => $metatags, + ], + ], + 'metatag' => [ + [ + 'tag' => 'meta', + 'attributes' => [ + 'name' => 'title', + 'content' => 'Llama | Drupal', + ], + ], + [ + 'tag' => 'link', + 'attributes' => [ + 'rel' => 'canonical', + 'href' => $canonical_url, + ], + ], + [ + 'tag' => 'meta', + 'attributes' => [ + 'name' => 'description', + 'content' => 'This is a description for use in Search Engines', + ], + ], + [ + 'tag' => 'meta', + 'attributes' => [ + 'name' => 'keywords', + 'content' => 'drupal8, testing, jsonapi, metatag', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheTags() { + return Cache::mergeTags(parent::getExpectedCacheTags(), ['config:system.site']); + } + +} diff --git a/tests/src/Functional/NodeJsonOutput.php b/tests/src/Functional/NodeJsonOutput.php index 164d59d..befe8e1 100644 --- a/tests/src/Functional/NodeJsonOutput.php +++ b/tests/src/Functional/NodeJsonOutput.php @@ -49,8 +49,12 @@ class NodeJsonOutput extends BrowserTestBase { public function testNode() { $this->provisionResource(); - /** @var \Drupal\node\NodeInterface $node */ - $node = $this->createContentTypeNode('Test JSON output', 'Testing JSON output for a content type'); + // Values for the test node. + $title = 'Test JSON output'; + $body = 'Testing JSON output for a content type'; + + /** @var\Drupal\node\NodeInterface $node */ + $node = $this->createContentTypeNode($title, $body); $url = $node->toUrl(); // Load the node's page. @@ -76,8 +80,18 @@ public function testNode() { } $this->assertTrue(isset($json->metatag)); if (isset($json->metatag)) { - $this->assertTrue($json->metatag->value->title == $node->label() . ' | Drupal'); - // @todo Test other meta tags. + foreach ($json->metatag as $metatag) { + if (isset($tag['attributes']['rel']) && $tag['attributes']['rel'] == 'canonical') { + // @todo How to get the absolute URL. + $this->assertEquals($tag->attributes->href, $url); + } + elseif (isset($tag['attributes']['name']) && $tag['attributes']['name'] == 'title') { + $this->assertEquals($tag->attributes->content, $node->label() . ' | Drupal'); + } + elseif (isset($tag['attributes']['name']) && $tag['attributes']['description'] == 'title') { + $this->assertEquals($tag->attributes->content, $body); + } + } } } diff --git a/tests/src/Kernel/MetatagSerializationTest.php b/tests/src/Kernel/MetatagSerializationTest.php index 177bd2b..99742ac 100644 --- a/tests/src/Kernel/MetatagSerializationTest.php +++ b/tests/src/Kernel/MetatagSerializationTest.php @@ -5,14 +5,18 @@ use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldConfig; use Drupal\entity_test\Entity\EntityTest; -use Drupal\Tests\field\Kernel\FieldKernelTestBase; +use Drupal\jsonapi\JsonApiResource\ResourceObject; +use Drupal\jsonapi\Normalizer\Value\CacheableNormalization; +use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; +use Drupal\metatag\Entity\MetatagDefaults; +use Drupal\user\Entity\User; /** * Tests metatag field serialization. * * @group metatag */ -class MetatagSerializationTest extends FieldKernelTestBase { +class MetatagSerializationTest extends EntityKernelTestBase { /** * {@inheritdoc} @@ -41,7 +45,6 @@ class MetatagSerializationTest extends FieldKernelTestBase { protected function setUp(): void { parent::setUp(); - $this->installEntitySchema('user'); $this->serializer = \Drupal::service('serializer'); // Create a generic metatag field. @@ -56,6 +59,27 @@ protected function setUp(): void { 'field_name' => 'field_test', 'bundle' => 'entity_test', ])->save(); + + $this->installConfig(['system', 'metatag']); + // Ensure a site name. + $this->config('system.site')->set('name', 'Test site')->save(); + + /** @var \Drupal\Core\Config\ConfigFactoryInterface $config_factory */ + $config_factory = $this->container->get('config.factory'); + $config_factory + ->getEditable('metatag.metatag_defaults.global') + ->set('tags.title', 'Global Title') + ->set('tags.description', 'Global description') + ->set('tags.keywords', 'drupal8, testing, jsonapi, metatag') + ->save(); + + MetatagDefaults::create([ + 'id' => 'entity_test', + 'tags' => [ + 'title' => '[entity_test:name] | [site:name]', + 'canonical_url' => '[entity_test:url]', + ], + ])->save(); } /** @@ -71,4 +95,69 @@ public function testMetatagDeserialization() { $this->serializer->deserialize($serialized, EntityTest::class, 'json'); } + /** + * Tests normalization of the computed metatag field. + */ + public function testJsonapiNormalization() { + $this->enableModules(['jsonapi']); + $serializer = $this->container->get('jsonapi.serializer'); + $resource_type_repository = $this->container->get('jsonapi.resource_type.repository'); + + $entity = EntityTest::create([ + 'name' => 'Llama', + 'type' => 'entity_test', + 'field_test' => [ + 'value' => [ + 'description' => 'This is a description for use in Search Engines', + ], + ], + ]); + assert($entity instanceof EntityTest); + // Validation initializes computed fields, verify this doesn't create a set + // of problematic field values. + $entity->validate(); + $entity->save(); + + + $resource_type = $resource_type_repository->get($entity->getEntityTypeId(), $entity->bundle()); + $resource_object = ResourceObject::createFromEntity($resource_type, $entity); + $cacheable_normalization = $serializer->normalize($resource_object, 'api_json', [ + 'resource_type' => $resource_type, + 'account' => User::getAnonymousUser(), + ]); + assert($cacheable_normalization instanceof CacheableNormalization); + $normalization = $cacheable_normalization->getNormalization(); + assert(is_array($normalization)); + $this->assertEquals([ + [ + 'tag' => 'link', + 'attributes' => [ + 'rel' => 'canonical', + 'href' => $entity->toUrl()->toString(), + ], + ], + [ + 'tag' => 'meta', + 'attributes' => [ + 'name' => 'title', + 'content' => 'Llama | Test site', + ], + ], + [ + 'tag' => 'meta', + 'attributes' => [ + 'name' => 'description', + 'content' => 'This is a description for use in Search Engines', + ], + ], + [ + 'tag' => 'meta', + 'attributes' => [ + 'name' => 'keywords', + 'content' => 'drupal8, testing, jsonapi, metatag', + ], + ], + ], $normalization['attributes']['metatag'], var_export($normalization['attributes']['metatag'], TRUE)); + } + }