Personalized paragraphs with Acquia Lift and Content Hub


Using Paragraphs to define components in Drupal 8 is a common approach to providing a flexible page building experience for your content creators. With the addition of Acquia Lift and Content Hub, you can now not only build intricate pages - you can personalize the content experience for site visitors.

Personalization with Acquia Lift and Content Hub

Acquia Lift is a personalization tool optimized for use with Drupal. The combination of Acquia Lift and Content Hub allows for entities created in Drupal to be published out to Content Hub and be made available through Lift to create a personalized experience for site visitors. In many instances, the personalized content used in Lift is created by adding new Blocks containing the personalized content, but not all Drupal sites utilize Blocks for content creation and page layout.

Personalizing paragraph components

To personalize a Paragraph component on a page, we’ll need to create a new derivative of that component with the personalized content for export to Content Hub. That means creating duplicate content somewhere within the Drupal site. This could be on a different content type specifically meant for personalization.To make this process easier on our content creators we developed a different approach. We added an additional Paragraphs reference to the content types we wanted to enable personalization on. This “Personalized Components” field can be used to add derivatives of components for each segment in Acquia Lift. The field is hidden from display on the resulting page, but the personalized Paragraph entities are published to Content Hub and available for use in Lift. This allows the content team to create and edit these derivatives in the same context as the content they’re personalizing. In addition, because Paragraphs do not have a title of their own, we can derive a title for them from combination of the title of their parent page and the type of component being added. This makes it easy for the personalization team to find the relevant content in Acquia Lift’s Experience Builder.In addition to all of this, we also added a “Personalization” tab. If a page has personalized components, this tab will appear for the content team allowing them to review the personalized components for that page.Keeping the personalized experience in the context of the original page makes it easier for the entire team to build and maintain personalized content.

The technical bits

There were a few hurdles in getting this all working. As mentioned above, Paragraph entities do not have a title property of their own. This means that when their data is exported to Content Hub, they all appear as “Untitled”. Clearly this doesn’t make for a very good user experience. To get around this limitation we leveraged one of the API hooks in the Acquia Content Hub module.

<?php/** * Implements hook_acquia_contenthub_cdf_from_drupal_alter(). */function mymodule_acquia_contenthub_cdf_from_drupal_alter(ContentHubEntity $cdf) {  $paragraph = \Drupal::service('entity.repository')->loadEntityByUuid($cdf->getType(), $cdf->getUuid());  /** @var \Drupal\node\Entity\Node $node */  $node = _get_parent_node($paragraph);  $node_title = $node->label();  $paragraph_bundle = $paragraph->bundle();  $paragraph_id = $paragraph->id();  $personalization_title = $node_title . ' - ' . $paragraph_bundle . ':' . $paragraph_id;  if ($cdf->getAttribute('title') == FALSE) {    $cdf->setAttributeValue('title', $personalization_title, 'en');  }}/** * Helper function for components to identify the current node/entity. */function _get_parent_node($entity) {  // Recursively look for a non-paragraph parent.  $parent = $entity->getParentEntity();  if ($parent instanceof Node) {    return $parent;  }  else {    return _get_parent_node($parent);  }}

This allows us to generate a title for use in Content Hub based on the title of the page we're personalizing the component on and the type of Paragraph being created.In addition to this, we also added a local task and NodeViewController to allow for viewing the personalized components. The local task is created by adding a mymodule.links.task.yml and mymodule.routing.yml to your custom module.*.links.task.yml:

personalization.content:  route_name: personalization.content  title: 'Personalization'  base_route: entity.node.canonical  weight: 100


personalization.content:  path: '/node/{node}/personalization'  defaults:    _controller: '\Drupal\mymodule\Controller\PersonalizationController::view'    _title: 'Personalized components'  requirements:    _custom_access: '\Drupal\mymodule\Controller\PersonalizationController::access'    node: \d+

The route is attached to our custom NodeViewController. This controller loads the latest revision of the current Node entity for the route and builds rendered output of a view mode which shows any personalized components.

<?phpnamespace Drupal\mymodule\Controller;use Drupal\Core\Access\AccessResult;use Drupal\Core\Entity\EntityInterface;use Drupal\node\Controller\NodeViewController;use Drupal\Core\Session\AccountInterface;/** * Defines a controller to render a single node. */class PersonalizationController extends NodeViewController {  /**   * {@inheritdoc}   */  public function view(EntityInterface $node, $view_mode = 'personalization', $langcode = NULL) {    // Make sure we're working from the latest revision.    $revision_ids = $this->entityManager->getStorage('node')      ->revisionIds($node);    $last_revision_id = end($revision_ids);    if ($node->getLoadedRevisionId() <> $last_revision_id) {      $node = $this->entityManager->getStorage('node')        ->loadRevision($last_revision_id);    }    $build = parent::view($node, $view_mode, $langcode);    return $build;  }  /**   * Custom access controller for personalized content.   */  public function access(AccountInterface $account, EntityInterface $node) {    /** @var \Drupal\node\Entity\Node $node */    $personalized = FALSE;    if ($account->hasPermission('access content overview')) {      if ($node->hasField('field_personalized_components')) {        $revision_ids = $this->entityManager->getStorage('node')          ->revisionIds($node);        $last_revision_id = end($revision_ids);        if ($node->getLoadedRevisionId() <> $last_revision_id) {          $node = $this->entityManager->getStorage('node')            ->loadRevision($last_revision_id);        }        if (!empty($node->get('field_personalized_components')->getValue())) {          $personalized = TRUE;        }      }    }    return AccessResult::allowedIf($personalized);  }}

The controller both provides the rendered output of our "Personalization" view mode, it also uses the access check to ensure that we have personalized components. If no components have been added, the "Personalization" tab will not be shown on the page.