Upgrade Your Drupal Skills

We trained 1,000+ Drupal Developers over the last decade.

See Advanced Courses NAH, I know Enough
Apr 09 2021
Apr 09
Pierce Lamb11 min read

Apr 9, 2021

This blog covers how to set up and use Personalized Paragraphs. If you’re looking for how Personalized Paragraphs was built, check out Personalized Paragraphs: Porting Smart Content to Paragraphs for Drupal. If you have any questions you can find me @plamb on the Drupal slack chat, there is also a #personalized_paragraphs channel.

You’ve visited the Personalized Paragraphs Module Page and downloaded the module. In doing that, composer should have also downloaded Smart Content, Entity Usage, and Paragraphs. You can verify this by visiting /admin/modules and checking that the modules are there. If they are not enabled, make sure Entity Usage, Paragraphs/Paragraphs Library, Smart Content, Smart Content Browser and Personalized Paragraphs are enabled. Now what?

Segment Sets

The entry point for using Smart Content is the Segment Set. Segment Sets define how you want to segment traffic via given conditions. We’ll be using the conditions out of the Smart Content Browser module as an example for this blog. As such, imagine that you want to segment traffic based on browser width. For your first segment, perhaps you want to segment visitors based on if their browser is greater than 1024px wide, or less than 1024px (I know this is a silly example, but it is nice for basic understanding). So based on the 1024px breakpoint, you want users above this width to see a certain experience and users below it to see a different one. We’ll define this in a Segment Set.

  • Visit /admin/structure/smart_content_segment_set
  • Click ‘Add Global Segment Set’.
  • Give your segment set a label like ‘Browser Width Segments.’
  • Click ‘Add Segment’
  • Give your segment a title like ‘less-than-1024px’
  • In the condition dropdown look for the ‘Browser’ header and select ‘Width’
  • Click ‘Add Condition’
  • Set the width to ‘less than’, ‘1024’px
  • Click ‘Add Segment’
  • Give your segment a title like ‘greater-than-1024px’
  • In the condition dropdown look for the ‘Browser’ header and select ‘Width’
  • Click ‘Add Condition’
  • Set the width to ‘greater than’, ‘1024’px
  • Click ‘Add Condition’
  • In the label area, add ‘Default Segment’
  • Under ‘Common’ select the condition ‘True’
  • Check ‘Set as default segment’
  • Save

With Browser Width Segments in place, we now have a way of segmenting traffic based on the width of the users browser (I recognize we only needed the less-than-1024px and default segments here, but it helps for learning to show all conditions). Users with browsers less than 1024px wide will see one piece of content and users with browsers greater will see a different piece. We added the default segment for clarity; in a situation where a user does not match any segment, it will display.

Personalized Content

Now we have to decide what content we’d like to personalize based on browser width. Let’s say we have a content type called ‘Homepage’ and we want to personalize the banner area of our homepage. Ideally we would use a paragraph to represent the banner and display different banners based on browser width. The first thing we want to do is create the paragraph that will be our banner.

  • Open the homepage node’s structure page (or whichever node you’re personalizing)
  • Take note of the fields it currently contains that constitute the banner
  • In another tab or window, visit /admin/structure/paragraphs_type/add
  • Give it a name like ‘Personalization — Homepage Banner’
  • Make sure to check the box that says ‘Allow adding to library.’ If this box is missing, Paragraphs Library likely is not enabled, check /admin/modules and see if its enabled
  • Inside your new Paragraph, re-create each field that represents the banner from the tab you have open on the homepage.
  • I typically add a boolean field to check if the paragraph is a personalized paragraph and
  • I typically add a campaign ID text field to add a campaign to the paragraph which can be pushed into the dataLayer
  • Neither of these are necessary for this tutorial, but may be to you in the future
  • Save your paragraph
  • Now visit /admin/content/paragraphs (added via Paragraphs Library)
  • Click ‘Add Library Item’
  • Add a label like ‘Homepage Banner — less-than-1024px’
  • Click the paragraph dropdown and select ‘Personalization — Homepage Banner’
  • Fill in the fields
  • Save
  • Click ‘Add Library Item’
  • Add a label like ‘Homepage Banner — greater-than-1024px’
  • Click the paragraph dropdown and select ‘Personalization — Homepage Banner’
  • Fill in the fields
  • Save
  • Do this one more time, but for ‘Homepage Banner — Default Banner’

If you’ve completed these steps, you now have 3 personalized banners that match the 3 segments we created above. Okay, so we have our segment set and our personalized content, now what?

Personalized Paragraphs

The next step is adding a Personalized Paragraph to the node you’re personalizing. In order to do that, we:

  • Add a new field to our homepage node.
  • Select the Add a New Field dropdown, find the ‘Reference Revisions’ header and select ‘Paragraph’
  • Give it a name like ‘Personalized Banner’
  • Save
  • On the ‘Field Settings’ page, under ‘allowed number of values’ select ‘Limited’ — 1 (this may change in the future)
  • Now, on the ‘Edit’ page for this new field, under the ‘Reference Type’ fieldset find and select ‘Personalized Paragraph’
  • Save

If you’ve completed all of these steps (note that you may need to flush caches), you can now load an instance of your homepage node and click ‘edit’ or add a new homepage node and you should see something that looks like this on the page (if not, click the ‘Add Personalized Paragraph button’):

The first step is to give your personalized paragraph a name that is unique within the page like ‘personalized_homepage_banner.’ It is possible to continue while leaving this field blank (and you can change it later), it is used only for identifying a personalized paragraph in front end code. Next we should find our segment set, ‘Browser Width Segments’ in the segment set dropdown and press ‘Select Segment Set.’ After it finishes loading, we should see 3 reactions which match the 3 segments we created earlier. In the paragraph dropdowns, we’ll select the respective paragraph we made for each segment, for e.g. ‘Homepage Banner — less-than-1024px’ will go in the segment titled ‘SEGMENT LESS-THAN-1024PX.’ After saving we will be redirected to viewing the saved page.

You may or may not see any change to your page at this point, it depends on your template file for this page. The content itself will be behind content.field_personalized_banner in your respective template file. One way to see that it’s on the page is to search the html for whatever you named the paragraph type used by Paragraphs Library. In our example it would be personalization-homepage-banner. This string should be found in the classes of an element in the HTML. The key, however, is that you now have access to the winning paragraph in your template file. Here’s an example of how we might display field_personalized_banner in a template file:

{% if content.field_personalized_banner %}


{{ content.field_personalized_banner }}




{% endif %}

Assuming you are seeing the content of your Personalized Paragraphs on the page, you can change your browser size to either larger than 1024px or less than, refresh, and you should get the other experience. If you don’t this is likely because Smart Content stores information in local browser storage regarding the experience you first received (specifically the _scs variable); it checks this variable before doing any processing for performance reasons. One way around this is to load a fresh incognito window, resize it to the test width and then load your page, another is to go into your local storage and delete the _scs variable.

There are many ways to segment traffic in Smart Content beyond browser conditions, Smart Content provides sub modules for Demandbase, UTM strings and 6sense. I’ve also written a module for Marketo RTP which we will be open sourcing soon. I wrote a blog about it here which can be used as a guide for writing your own connector.

At this point in using Personalized Paragraphs, there are a lot of ways you could go with displaying the front end and you definitely don’t need to read the remainder of this blog. I’ll cover the way my org displays it, but I’ll note that there isn’t some sort of best practice, it is just what works for us. You absolutely do not need to use Personalized Paragraphs in this manner and I encourage you to experiment and find what works for you.

Front end processing

You may have noticed that our personalized banner field is ‘overwriting’ the existing banner fields on our homepage node (as opposed to replacing). That is, our original banner fields and personalized banner field are doing the same ‘thing’ and now we have two of them on the page. The reason for this decision is based on how Smart Content works. When the page is loaded Smart Content decides which paragraph has won then uses ajax to retrieve that paragraph and load it onto the page. If we replaced the original banner with the Personalized Paragraph, the banner would appear to ‘pop-down’ after the page had begun loading whenever the ajax returned. We felt that this experience was worse than the small performance hit of loading both banners onto the page (it also provides a nice fall back should anything fail). This ‘pop-down’ effect of course only occurs when you’re personalizing a portion of the page that contributes to the flow; if it was an element that doesn’t you’d see more of a ‘pop-in’ effect which is less jarring.

Because we are loading two of the same ‘things’ onto the page, we need to use javascript to decide which one gets displayed. We need a way to inform some JS code of whether or not a decision paragraph is on the page (else display fallback) and also differentiate between which decision paragraph we’re operating on (in case there is more than one on the page). The entry point for this goes back to how Personalized Paragraphs was built, to a more controversial point near the end. In a hook_preprocess_field__entity_reference_revisions function we load the Personalized Paragraph onto the page then execute this code:

$para_data = [
'token' => $token,
];
$has_name = !$para->get('field_machine_name')->isEmpty();
$name = $has_name ? $para->get('field_machine_name')->getValue()[0]['value'] : '';
$variables['items'][0]['content']['#attached']['drupalSettings']['decision_paragraphs'][$name] = $para_data;

In essence, this code stores the information we’re after in drupalSettings so we have access to it in javascript. We get a key, [‘decision_paragraphs’] which contains an associative array of personalized paragraphs machine names which have their decision tokens as values. With this information we can now manipulate the front end as we need.

Before I show how we display the default or the winner on the front end, I want to reiterate that this is not a best practice necessarily; it’s just a design decision that works for us. All of the below code is stored in a custom module specifically for personalization. First, we create a js file called ‘general_personaliztion.js’ that will be attached on any page where Personalized Paragraphs run. To continue following our example, we add this at the top of our homepage template:

{{ attach_library(‘/general_personalization_js’) }}

Following the style much of the smart content javascript is written in, this file defines an object that can be accessed later by other JS files:

Drupal.behaviors.personalization = {};

Drupal.behaviors.personalization.test_for_decision_paragraph =
function(paragraphs_and_functions, settings) {
...
};</span>

This first function will test if a decision paragraph is on the page:

function(paragraphs_and_functions, settings) {
if(settings.hasOwnProperty('decision_paragraphs')) {
paragraphs_and_functions.forEach((display_paragraphs, paragraph_name) => {
if (!settings.decision_paragraphs.hasOwnProperty(paragraph_name)) {
var show_default = display_paragraphs['default'];
show_default();
}
});
} else {
paragraphs_and_functions.forEach((display_paragraphs, paragraph_name) => {
var show_default = display_paragraphs['default'];
show_default();
});
}
};

It first tests to see if the key ‘decision_paragraphs’ exists in the passed settings array, if it doesn’t it takes the passed paragraphs_and_functions Map, iterates over each Personalized Paragraph machine name, grabs the function out of it that displays the default experience and executes it. If ‘decision_paragraphs’ does exist, it does the same iteration checking to see if the Map of machine names passed to it are represented in that decision_paragraphs key, if not it gets their default function and executes it. This means we can change machine names, delete personalized paragraphs etc and guarantee that the default experience will display no matter what we do. So how do we call this function with the right parameters?

We create a new file, personalization_homepage.js which is now attached directly underneath the previous file in the homepage template:

{{ attach_library('/general_personalization_js') }}
{{ attach_library(‘/personalization_homepage') }}

This file will only ever be attached to the homepage template. Inside this file, we create an object to represent the default and personalized experiences for our banner:

var banner_display_functions = {
'default': personalization_default_banner,
'personalized': personalization_set_banner
};

The values here are functions defined elsewhere in the file that execute the JS necessary to display either the default or personalized experiences. Next we create a Map like so:

var paragraphs_and_functions = new Map([
['personalization_homepage_banner', banner_display_functions],
]);

The map has personalized paragraph machine names as keys and the display object as values. You can imagine with another Personalized Paragraph on the page, we’d just add it as a member here. An unfortunate side effect of this design is the hardcoding of those machine names in this JS file. I’m sure there is a way around this, but for performance reasons and how often we change these paragraphs, this works for us. With this in place, calling our test_for_decision_paragraphs function above is straight forward:

Drupal.behaviors.personalizationHomepage = {
attach: function (context, settings) {
if (context === document) { Drupal.behaviors.personalization.test_for_decision_paragraph(paragraphs_and_functions, drupalSettings);
}

...
}

This populates test_for_decision_paragraph with the correct values. It is inside context === document to ensure that it executes at only the right time (without this control the default banner will ‘flash’ multiple times). So now the code is in place to test for a decision paragraph and display the default experience if it is not there. What about displaying the winning personalized experience?

We create another function inside general_personalization.js, ‘test_for_winner’:

Drupal.behaviors.personalization.test_for_winner =
function(current_decision_para_token, paragraphs_and_functions) {
//Iterate over the display blocks, matching display block UUID to decision_block_token
paragraphs_and_functions.forEach((display_paragraphs, paragraph_name) => {
if(drupalSettings.decision_paragraphs.hasOwnProperty(paragraph_name)) {
var decision_paragraph_token = drupalSettings.decision_paragraphs[paragraph_name].token;
if (decision_paragraph_token === current_decision_para_token) {
var show_winner = display_paragraphs['personalized'];
show_winner($(event.detail.response_html))
}
}
});

};</span>

(note it may be event.detail.data for you)

Remember how we talked about that hook_preprocess_hook in which we attached the decision token of a personalized paragraph keyed by machine name? Our test_for_winner iterates those machine names extracting the decision token and comparing it to the decision token of the current winning paragraph; when a match is found, it uses the winning machine name to find and run the function that displays the personalized experience and executes it. So how do we call this function? Inside the same Drupal.behaviors.personalizationHomepage{…} in personalization_homepage.js we add:

window.addEventListener('smart_content_decision', function (event) {
Drupal.behaviors.personalization.test_for_winner(event.detail.decision_token, paragraphs_and_functions);
});
}

(note: we’ve made some edits to the broadcasted event object internally. I invite you to print this object and see what it contains.

And here is where we touch one of the areas Smart Content creates an offering for front end processing: when a winner is chosen by Smart Content, it broadcasts a ‘smart_content_decision’ event which contains a bunch of information including the decision token of the winning content. This is what test_for_winner uses to compare to existing personalized paragraphs to select a display function. Smart Content will broadcast this event for every winning paragraph (e.g. if we have n personalized paragraphs on the page, the event will broadcast n times with each paragraph’s winning token) and test_for_winner allows us to know which paragraph it’s currently broadcasting for and allows us to execute that paragraphs personalized display function.

There are a number of other cool things we can do with this broadcasted event, for example, pushing the campaign name of the winning paragraph into the dataLayer, but I will leave this for another time.

I hope that the example we’ve used throughout has helped you to better understand how to use Personalized Paragraphs and the brief tour of our front end design has given you a starting point.

If you’re looking for how Personalized Paragraphs was built, check out Personalized Paragraphs: Porting Smart Content to Paragraphs for Drupal. If you have any questions you can find me @plamb on the Drupal slack chat, there is also a #personalized_paragraphs channel.

Mar 15 2021
Mar 15
Pierce Lamb24 min read

Mar 15, 2021

This blog covers how the Personalized Paragraphs module was built, if you’re looking for how to use Personalized Paragraphs, check out the How To Use Personalized Paragraphs blog. If you have any questions you can find me @plamb on the Drupal slack chat, there is also a #personalized_paragraphs channel.

In 2020, I was tasked by my organization with finding or developing a personalization solution for our Drupal-based website. By personalization, I mean a tool that will match anonymous users into segments and display a certain piece of content based on that segmentation. Brief searching led me into the arms of Smart Content, a platform for personalization developed by the clever folks over at Elevated Third. Smart Content is a toolset for managing segmentation, decisions, reactions etc all within the Drupal framework. As a general platform, it makes no assumptions about how you want to, say, display the content to the user or pass results back to your analytics platform. However, it comes with a number of sub modules so you don’t need to develop these solutions on your own. Out of the box, Smart Content includes ‘Smart Content Block’ which allows you to utilize Drupal’s Block interface to manage your personalized content. There are a number of reasons this was a good idea, but it also presented some difficulties (at least for us).

After installing Smart Content, the most straightforward way to use personalized blocks was to create a Smart Content Decision Block in the block layout builder. However, to get control over where the block was placed (i.e. instead of in a region across many pages), we needed to disable the block, load it independently in a preprocess and attach it to the relevant page’s theme variables; a bit cumbersome. I recognize that there are other options like Block Field out there, but this appeared to be the most out-of-the-box way to use Smart Content Block. As a block-based solution, we found that we had to make changes to the blocks on prod then drag the changes back to our development branches and environments because exporting block config would cause UUID issues on merge. As our use cases grew, this became more cumbersome. In addition, my organization heavily leans on Paragraphs to power content inside of Nodes (and very sparingly uses blocks). After about 6 months of using Smart Content we decided we should see if we could utilize Paragraphs to power personalization.

The funny thing about Paragraphs is that they don’t ‘float free’ of Nodes in the same way that blocks do; at their core they are referenced by Nodes. Or at least I thought. When we discussed using paragraphs I did some brief research and saw that others had successfully attempted porting Smart Content to Paragraphs. Upon testing this module, I found that it relied on an old, fairly different version of Smart Content and also included a lot of extra code relevant to that organization’s use case. Further, it lacked the extremely well-thought interface for adding a segment set and reactions that’s contained in Smart Content Block. However, the key insight its author’s included was the use of the Paragraphs Library. Paragraphs Library is an optional sub module of Paragraphs that was quietly added in 2018 and allows users to create Paragraphs that ‘float free’ in just the way we’d need to personalize them. With this in hand, I thought I would try porting the experience of Smart Content Block to Paragraphs.

The Supporting Structure

The porting process began by digging into the smart_content_block sub-module of Smart Content. The entry point is Plugin/Block/DecisionBlock.php which appeared to be an Annotated Block plugin. When constructed, it had a control structure which created a further plugin ‘multiple_block_decision’ which I found defined in Plugin/smart_content_block/Decision. Further, in one of MultipleBlockDecision’s functions, it creates an instance of the display_blocks plugin which was defined in Plugin/smart_content_block/Reaction:

I knew that these three files combined must work together to create the nice user experience that administrating smart_content_block currently has. So I set about to emulate them, but with Paragraphs instead of blocks.

Paragraphs did not come pre-packaged with an obvious Annotation plugin to achieve what I wanted, so I created one. I sought to mimic the one included with Blocks and thus in the Annotation/PersonalizedParagaph.php file, I defined it as: class PersonalizedParagraph extends Plugin {…}. With this in hand I could now create a Plugin/PersonalizedParagraph/DecisionParagraph.php that mimicked smart_content_block’s DecisionBlock:

/**
* Class Decision Paragraph.
*
* @package Drupal\personalized_paragraphs\Plugin\PersonalizedParagraph
*
* @PersonalizedParagraph(
* id = "personalized_paragraph",
* label = @Translation("Personalized Paragraph")
* )
*/

However, before I defined class DecisionParagraph, I knew I needed to extend something similar to BlockBase and implement ContainerFactoryPluginInterface just like DecisionBlock.php does. I opened Core/Block/BlockBase.php and attempted to mirror it as closely as I could. I created personalized_paragraph/Plugin/PersonalizedParagraphBase.php. Here is the comparison between the two:

abstract class BlockBase extends ContextAwarePluginBase implements BlockPluginInterface, PluginWithFormsInterface, PreviewFallbackInterface {

use BlockPluginTrait;
use ContextAwarePluginAssignmentTrait;
...
}</span>abstract class PersonalizedParagraphsBase extends ContextAwarePluginBase implements PersonalizedParagraphsInterface, PluginWithFormsInterface, PreviewFallbackInterface {

use ContextAwarePluginAssignmentTrait;
use MessengerTrait;
use PluginWithFormsTrait;
...
}</span>

And other than cosmetic function name changes, the classes are largely the same. They implement PluginWithFormsInterface which is defined as:

Plugin forms are embeddable forms referenced by the plugin annotation. Used by plugin types which have a larger number of plugin-specific forms.

Which certainly sounds like exactly what we need (a way to plug one form into another). You may have noticed one difference though, I had to create an interface, PersonalizedParagraphsInterface to mirror BlockPluginInterface. Again, these two files are largely the same, I’ll leave it to the reader to check them out.

At this point, I now had the beginning of a DecisionParagraph.php and the files that back it, Annotation/PersonalizedParagraphs.php, PersonalizedParagraphsBase.php and PersonalizedParagraphsInterface.php. Since DecisionParagraph.php is a plugin, I knew I’d need a Plugin Manager as well. My next step was to create a /Plugin/PersonalizedParagraphsManager.php. This file is as default as it gets when it comes to a Plugin Manager:

class PersonalizedParagraphsManager extends DefaultPluginManager {

/**
* Constructs a new ParagraphHandlerManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct(
'Plugin/PersonalizedParagraph',
$namespaces,
$module_handler,
'Drupal\personalized_paragraphs\Plugin\PersonalizedParagraphsInterface',
'Drupal\personalized_paragraphs\Annotation\PersonalizedParagraph'
);

$this->alterInfo('personalized_paragraphs_personalized_paragraphs_info');
$this->setCacheBackend($cache_backend, 'personalized_paragraphs_personalized_paragraphs_plugins');
}
}</span>

You can see in its constructor that it gets created with all the key files we need to create the DecisionParagraph plugin. Note that a plugin manager also requires a module.services.yml which I defined as follows:

services:
plugin.manager.personalized_paragraphs:
class: Drupal\personalized_paragraphs\Plugin\PersonalizedParagraphsManager
parent: default_plugin_manager

I knew at this point I must be really close. However if you recall the screenshot above, I was still missing mirrors of MultipleBlockDecision and DisplayBlocks. My next step was to create /Plugin/smart_content/Decision/MultipleParagraphDecision.php and /Plugin/smart_content/Reaction/DisplayParagraphs.php. While over the course of building Personalized Paragraphs these files would get edited, the class stubs would be identical. This is largely because Smart Content creates Annotated Plugin types for many of its core functions which makes it extremely easy to extend. Comparing MultipleBlockDecision and MultipleParagraphDecision:

/**
* Provides a 'Multiple Block Decision' Decision plugin.
*
* @SmartDecision(
* id = "multiple_block_decision",
* label = @Translation("Multiple Block Decision"),
* )
*/
class MultipleBlockDecision extends DecisionBase implements PlaceholderDecisionInterface {
...
}
/**
* Provides a 'Multiple Paragraph Decision' Decision plugin.
*
* @SmartDecision(
* id = "multiple_paragraph_decision",
* label = @Translation("Multiple Paragraph Decision"),
* )
*/
class MultipleParagraphDecision extends DecisionBase implements PlaceholderDecisionInterface {
...
}

And this is isomorphic in the case of DisplayBlocks.php and DisplayParagraphs.php. With MultipleParagraphDecision and DisplayParagraphs in place, I just had to go change where they were created from multiple_block_decision -> multiple_paragraph_decision in DecisionParagraph and display_blocks -> display_paragraphs in MultipleParagraphDecision. At this point, my /src/ folder structure looked like this:

Very close to the structure of smart_content_block. Okay so now I have all this plugin code defined, but how will Drupal know when and where to create instances of personalized_paragraph?

When and Where does this run?

The first step was to create a Paragraph Type called ‘Personalized Paragraph’. Simple enough. At the time I created this, I did not think it would need any fields, but we will discuss later why I did. The Personalized Paragraph Type would be the entry point for a Paragraph inside a node to basically say “hey I’m going to provide personalized content.”

Our first ever use case for personalized content was our homepage banner, so to test my code, I created another Paragraph type called Personalization — Homepage Banner (the reason for this naming convention is that you can imagine many personalization use cases all being grouped together by starting with ‘Personalization -’). The key switch I needed to flip in creating this test Paragraph was this:

“Allow adding to library” meant that this specific Paragraph Type could have members created in the Paragraphs Library that ‘float free’ from any node. With that flipped, I just needed to mirror the fields that produce our homepage banner in this Paragraph Type. Now I could load the Paragraphs Library, /admin/content/paragraphs, and create every personalized paragraph I needed to support personalizing the homepage banner. This step is discussed in more detail in the How To Use Personalized Paragraphs blog.

Now, in order to test the ‘when and where’ question above, I loaded our ‘homepage’ content type and added a new field, ‘Personalized Banner’ that referenced a ‘Paragraph’:

And in the Paragraph Type to reference I selected Personalized Paragraph:

The Personalized Banner field was now telling our homepage node that it would contain personalized content. With this structure in place, I could now programmatically detect that a personalized_paragraph was being edited in the edit form of any homepage node and displayed when a homepage node was viewed. Further, I’d be able to use the Paragraphs I’d added to the library to display when different Smart Content segments were matched.

The Form Creation Journey

I wanted to get the node edit form working first, so in personalized_paragraphs.module, I needed to detect that a Paragraph of type personalized_paragraph was in a form. I created a:

function personalized_paragraphs_field_widget_entity_reference_paragraphs_form_alter(&$element, FormStateInterface &$form_state, $context){...}

Which is a form_alter hook that I knew would run for every Paragraph in a form, so I immediately needed to narrow it to personalized_paragraphs Paragraphs:

$type = $element['#paragraph_type'];
if($type == 'personalized_paragraph'){
...
}
}

So I was hooked into any form that contains a Personalized Paragraph. This captures the ‘when and where’ that I needed to load the plugin code defined above. So the next step was to load the plugin inside our control structure:

if ($plugin = personalized_paragraphs_get_handler('personalized_paragraph')) {
$build_form = $plugin->buildConfigurationForm([], $form_state);
$element['subform']['smart_content'] = $build_form;
}

And the code for _get_handler:

function personalized_paragraphs_get_handler($plugin_name) {
$plugin_manager = \Drupal::service('plugin.manager.personalized_paragraphs');
$definitions = $plugin_manager->getDefinitions();

foreach ($definitions as $plugin_id => $definition) {
if ($plugin_id == $plugin_name) {
return $plugin_manager->createInstance($plugin_id);
}
}
return false;
}</span>

So what’s going on here? Well, we know we’re acting on the form that builds a Paragraphs edit interface. Once we know that, we can go ahead and load the Annotation plugin we defined in the beginning (personalized_paragraph) using the custom plugin manager we defined (plugin.manager.personalized_paragraphs). This will give us an instance of DecisionParagraph. With that instance, we can call DecisionParagraph’s buildConfigurationForm method passing it an empty array. When it returns, that empty array will be a filled render array which mirrors the smart_content_block user experience exactly, but within a Personalized Paragraph. So all we need to do is attach it in its own key (smart_content) to the element’s ‘subform’ and it will display in the right area.

So what is happening inside buildConfigurationForm? I won’t be going too in depth in here as most of this is simply mimicking smart_content_block. Suffice it to say that when the DecisionParagraph is constructed, an instance of MultipleParagraphDecision is also constructed. ->buildConfigurationForm ends up being called in both classes. You can view the code in each to get a sense of how the form render array is built. Now, with this code in place, we end up with an experience exactly like smart_content_block, but inside a Paragraph inside a Node; this is what the personalized paragraph in my homepage type looked like:

This is ultimately what anyone who has used smart_content_block would want out of a Paragraphs-based version. Since we had been using smart_content_block, we had a number of Segment Sets already to test from. Here is the result of selecting our Homepage Customer Block Segment Set:

I would like to digress for a moment here to discuss one of the most difficult bugs I encountered in the process. Getting the ‘Select Segment Set’ ajax to work was an absolute journey. On first implementation, the returned content was an empty . That class name led me to ManagedFile.php which is a class that provides an AJAX/progress aware widget for uploading and saving a file. This of course was odd because this element was not an uploading/file widget, however this particular Node edit form did have elements like this on the page. After stepping through execution in both Symfony and core’s FormBuilder what I discovered is this (line 1109 of FormBuilder):

// If a form contains a single textfield, and the ENTER key is pressed
// within it, Internet Explorer submits the form with no POST data
// identifying any submit button. Other browsers submit POST data as
// though the user clicked the first button. Therefore, to be as
// consistent as we can be across browsers, if no 'triggering_element' has
// been identified yet, default it to the first button.
$buttons = $form_state->getButtons();
if (!$form_state->isProgrammed() && !$form_state->getTriggeringElement() && !empty($buttons)) {
$form_state->setTriggeringElement($buttons[0]);
}

In short, I was pressing ‘Select Segment Set’, the triggering element wasn’t being found as the form was rebuilt in FormBuilder, and the code was just setting it to the first found button on the page (hence MangedFile.php). I have no objection with the comment or reason for this code block, but it makes it extremely difficult to figure out why your AJAX button isn’t working. If, for example, it triggered a log statement inside the if that said something like “the triggering element could not be matched to an element on the page during form build” it would have saved me multiple days of pain.

FormBuilder attempts to match the triggering element by comparing the name attribute of the pressed button to the name attributes of buttons on the page as it rebuilds the form. The issue was occurring because smart_content_block creates the name from a UUID it generates when MultipleDecisionBlock is created. In Personalized Paragraphs, this creation occurs inside a field_widget_entity_reference_paragraphs_form_alter which is called again while the form is rebuilt. As such a new UUID is generated, and FormBuilder cannot match the two elements.

The solution was to create a name that is unique within the edit form (so it can be matched), but does not change when the form is rebuilt. I added this above ->buildConfigurationForm:

$parent_field = $context['items']->getName();
$plugin->setConfigurationValue('parent', $parent_field);
$build_form = $plugin->buildConfigurationForm([], $form_state);
$element['subform']['smart_content'] = $build_form;

The machine name of the field that contains the personalized paragraph is passed along configuration values in DecisionParagraph to MultipleParagraphDecision where it is extracted and used to create the name attribute of the button. This solved the issue. Okay, now back to the returned Reactions.

The class that builds the Reactions after a Segment Set is selected is DisplayParagraphs; an instance is created for each Reaction, the code that executes this is found in MultipleParagraphDecision inside the stubDecision() method and buildSelectedSegmentSet method if the Reactions already exist. The Reactions are the first place we depart from the smart_content_block experience.

Seasoned users of smart_content_block will notice that the ‘Add Block’ button is missing. One of the most difficult problems I encountered while porting smart_content_block was getting the ajax buttons in the form experience to work correctly. Because of this, I opted to just hide them here (commented the code that built them in DisplayParagraphs.php) and instead validate and submit whatever is in the select dropdown at submission time. I liked the simplicity of this anyway, but it means that a given reaction could never contain more than one paragraph. This is an area ripe for contribution inside personalized_paragraphs.

In order to populate the select dropdowns in the Reactions, I first needed to go create some test Paragraphs Library items that would exist in them. I loaded /admin/content/paragraphs, selected ‘Add Library Item’ and then Add -> ‘Personalization — Homepage Banner’ (the Paragraph I created earlier to mimic the content I’m personalizing). I created a few instances of this Paragraph. Now I could go back to DisplayParagraphs.php and figure out how to retrieve these paragraphs.

Looking at the buildConfigurationForm method, it was clear that an array of $options was built up and passed to the form render array, so I needed to simply create some new options. Since we’re dealing with ContentEntities now, this was pretty easy:

$pg_lib_conn = $this->entityTypeManager->getStorage('paragraphs_library_item');
$paragraphs = $pg_lib_conn->loadMultiple();
$options = [];
$options[''] = "- Select a Paragraph -";
foreach($paragraphs as $paragraph){
$maybe_parent = $paragraph->get('paragraphs')->referencedEntities();
if(!empty($maybe_parent)) {
$parent_name = $maybe_parent[0]->bundle();
$options[$parent_name][$paragraph->id()] = $paragraph->label();
} else {
$options[$paragraph->id()] = $paragraph->label();
}
}

The code loads all of the existing paragraphs_library_items and splits them by Paragraph Type for easy selection in the dropdown which is how it works in smart_content_block. $options is later passed to a render array representing the select dropdown.

With this in place, we’re able to add a personalized_paragraph to a node, select a segment set, load reactions for that segment set and select the personalized paragraphs we want to display. Beautiful. What happens when we press Save?

The Form Submission Journey

Due to the way I was loading the Segment/Reaction form into the node edit form, none of the existing submit handlers were called by default. Thankfully the submit function attached to DecisionParagraph, paragraphSubmit, was designed in a way that it calls all the nested submit functions, i.e. MultipleParagraphDecision::submitConfigurationForm, which loops while calling DisplayParagraphs::submitConfigurationForm. So all I needed to do was attach paragraphSubmit as a custom handler like so:

function personalized_paragraphs_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id){
$node = $form_state->getFormObject()->getEntity();
$personalized_fields = _has_personalized_paragraph($node);
if(!empty($personalized_fields)){
if ($plugin = personalized_paragraphs_get_handler('personalized_paragraph')) {
_add_form_submits($form, $plugin);
}
}
}

For reference, _has_personalized_paragraph looks like this:

function _has_personalized_paragraph($node){
$fields = [];
foreach ($node->getFields() as $field_id => $field) {
$settings = $field->getSettings();
$has_settings = array_key_exists('handler_settings', $settings);
if ($has_settings) {
$has_bundle = array_key_exists('target_bundles', $settings['handler_settings']);
if ($has_bundle) {
foreach ($settings['handler_settings']['target_bundles'] as $id1 => $id2) {
if ($id1 == 'personalized_paragraph' || $id2 == 'personalized_paragraph') {
array_push($fields, $field_id);
}
}
}
}
}
return $fields;
}

I’ll note here that it certainly ‘feels’ like there should be a more Drupal-y way to do this. I’ll also note that at the time of this writing, PP’s have not been tested on Paragraphs that contain them more than one level deep; my sense is that this function would fail in that case (another area ripe for contributing to the module).

Okay, so now we know that when someone presses ‘save’ in the node edit form, our custom handler will run.

paragraphSubmit departs pretty heavily from DecisionBlock::blockSubmit. First, since a Node could have an arbitrary number of personalized paragraphs, we must loop over $form_state’s userInput and detect all fields that have personalized paragraphs. Once we’ve narrowed to just the personalized fields, we loop over those and feed their subforms to similar code that existed in DecisionBlock::blockSubmit.

paragraphSubmit narrows to the form array for a given personalized paragraph and then passes that array to DecisionStorageBase::getWidgetState (a smart_content class) which uses NestedArray::getValue(). Users of this function know you pass an array of parent keys and a form to ::getValue() and it gives back null or a value. When I initially wrote this code, I hardcoded ‘0’ as one of the parents, thinking this would never change. However, one big difference in smart_content_block and personalized_paragraphs is that by virtue of being a paragraph, a user can press ‘Remove’, ‘Confirm Removal’ and ‘Add Personalized Paragraph’. In the form array that represents the personalized paragraph, pressing these buttons will increment that number by 1. So in paragraphSubmit, it will now have a 1 key instead of a 0 key. To handle this, I wrote an array_filter to find the only numerical key in the form array:

$widget_state = $form[$field_name]['widget'];
$filter_widget = array_filter(
$widget_state,
function ($key) {
return is_numeric($key);
},
ARRAY_FILTER_USE_KEY
);
$digit = array_key_first($filter_widget);

$parents = [$field_name, 'widget', $digit, 'subform', 'smart_content'];</span>

In comments above, it’s noted this will fail if someone attempts to create a field that has multiple Personalized Paragraphs in it (array_key_first will return only the first one). This is another area ripe for contribution in Personalized Paragraphs.

DecisionStorageBase::getWidgetState gets a decision storage representation from the form state and returns it. I added code here to ensure that the decision is always of type ContentEntity and not ConfigEntity (smart content defines both). Next, the code uses the $parents array and passed in $form variable to get the actual $element we’re currently submitting. It then runs this code:

if ($element) {
// Get the decision from storage.
$decision = $this->getDecisionStorage()->getDecision();
if ($decision->getSegmentSetStorage()) {
// Submit the form with the decision.
SegmentSetConfigEntityForm::pluginFormSubmit($decision, $element, $form_state, ['decision']);
// Set the decision to storage.
$this->getDecisionStorage()->setDecision($decision);
}
}

It’s easy to miss, but this line:

SegmentSetConfigEntityForm::pluginFormSubmit($decision, $element, $form_state, ['decision']);

Is what submits the current $element to the submit handler in MultipleParagraphDecision whose submit handler will ultimately call DisplayParagraphs submit handler ($decision in this case is the instance of MultipleParagraphDecision). So the chain of events is like this:

  • Node_form_alter -> add paragraphSubmit as a custom handler.
  • On submission, paragraphSubmit calls MultipleParagraphDecision::submitConfigurationFrom (via ::pluginFormSubmit)
  • This function has a looping structure which calls DisplayParagraphs::submitConfigurationForm for each Reaction (via ::pluginFormSubmit).

Before completing our walk through of paragraphSubmit, let’s follow the execution and dive into these submit handlers.

MultipleParagraphDecision::submitConfigurationForm is largely identical to MultipleBlockDecision::submitConfigurationForm. It gets the SegmentSetStorage for the current submission and loops for each Segment, creating a DisplayParagraphs instance for that segment uuid. It achieves this by calling:

$reaction = $this->getReaction($segment->getUuid());
SegmentSetConfigEntityForm::pluginFormSubmit($reaction, $form, $form_state, [
'decision_settings',
'segments',
$uuid,
'settings',
'reaction_settings',
'plugin_form',
]);

Where $reaction ends up being an instance of DisplayParagraphs for the current segment uuid. ::pluginFormSubmit is called like above which calls DisplayParagraphs::submitConfigurationForm.

This function starts by calling DisplayParagraphs::getParagraphs which is used all over DisplayParagraphs and modeled after DisplayBlocks::getBlocks. Because the block implementation can use PluginCollections, it’s easy for getBlocks to grab whatever block information is stored on the current reaction. I could not find a way to emulate this with paragraphs, so I opted to get paragraph information directly from the form input. If you recall my solution to the ajax button matching problem above (passing the unique machine ID of the parent field backwards via config values), getParagraphs implementation will look familiar.

First, for any call to ->getParagraphs that is not during validation or submission the caller passes an empty array which tells getParagraphs to try and get the Reaction information from the current configuration values (i.e. while its building dropdowns or sending an ajax response). Second, when called during validation or submission, the caller passes the result of $form_state->getUserInput(). After the non-empty passed array is detected, this code executes:

$field_name = $this->getConfiguration()['parent_field'];
$widget_state = $user_input[$field_name];
$filter_widget = array_filter(
$widget_state,
function ($key) {
return is_numeric($key);
},
ARRAY_FILTER_USE_KEY
);
$digit = array_key_first($filter_widget);
$parents = [$digit, 'subform','smart_content','decision','decision_settings','segments'];
$reaction_settings = NestedArray::getValue($user_input[$field_name], $parents);
$reaction_arr = $reaction_settings[$this->getSegmentDependencyId()];
$paragraphs[$field_name][$this->getSegmentDependencyId()] = $reaction_arr;

getParagraphs extracts the machine ID of the current parent_field out of its configuration values and uses it to parse the UserInput array. The value is extracted similarly to paragraphSubmit (filter for a numeric key and call ::getValue()) and then an array of reaction information keyed by parent field name and current segment set UUID is created and passed back to the caller.

submitConfigurationForm then extracts the paragraph ID out of this array and creates an array that will store this information in the configuration values of this instance of DisplayParagaphs (highly similar to DisplayBlocks). At this point control switches back to MultipleParagraphDecision and the $reaction variable now contains the updated configuration values. The reaction information is then set via DecisionBase::setReaction(), the ReactionPluginCollections config is updated and the instance variable MultipleParagraphsDecision->reactions is updated. At this point, control then goes back to paragraphSubmit.

Before we step back there, I wanted to note that DisplayParagraphs::getParagraphs is another area ripe for contribution. I skipped over the first portion of this function; this function is called in multiple areas of DisplayParagraphs to either get submitted form values (which we discussed) or to retrieve the existing values that are already in the configuration. As such, the function is built around a main control structure that branches based on an empty user input. This could definitely be done in a more clean, readable way.

Okay, back to paragraphSubmit. At this point we have completed everything that was called inside ::pluginFormSubmit which stepped through all of our nested submission code. The $decision variable has been updated with all of that information and the decision is now set like this:

// Submit the form with the decision.
SegmentSetConfigEntityForm::pluginFormSubmit($decision, $element, $form_state, ['decision']);
// Set the decision to storage.
$this->getDecisionStorage()->setDecision($decision);

Now that we have built the submitted decision, we need to save it and inform the paragraph it’s contained in that this decision is attached to it:

if ($this->getDecisionStorage()) {
$node = $form_state->getFormObject()->getEntity();
$personalized_para = $node->get($field_name)->referencedEntities();
if($personalized_para == null) {
//Paragraphs never created and saved a personalized_paragraph.
\Drupal::logger('personalized_paragraphs')->notice("The node: ".$node->id()."has a personalized paragraph (PP) and was saved, but no PP was created");
}else {
$personalized_para = $personalized_para[0];
}
if(!$node->isDefaultRevision()){
//A drafted node was saved
$this->getDecisionStorage()->setnewRevision();
}
$saved_decision = $this->getDecisionStorage()->save();
$personalized_para->set('field_decision_content_token', $saved_decision->getDecision()->getToken());
$personalized_para->save();
if ($saved_decision instanceof RevisionableParentEntityUsageInterface) {
$has_usage = $saved_decision->getUsage();
if(!empty($has_usage)){
$saved_decision->deleteUsage();
}
$saved_decision->addUsage($personalized_para);
}
}

We first get the $node out of the $form_state; recall that we are inside a structure that is looping over all personalized fields, so we use $field_name to get the referenced personalized paragraph out of the field. In an earlier version, the paragraphSubmit handler ran first before any other handler; because of this, on a new node, the paragraph had not been saved yet and ->referencedEntities returned null. With it executing last this should never happen, but I left a check and log statement for it just in case there is something I have not thought of. Next we check for defaultRevision so we can inform the decision content that it’s part of a draft instead of a published node. Finally we save the decision, pass the returned token to the personalized paragraphs hidden field that stores it and then add to the decision_content_usage table which tracks usage of decisions and their parents.

At this point we have handled the Create and Update states of a personalized paragraph inside a Node edit form. What about the read state? Now that we’ve attached the decision token to the decision_content_token field of our personalized paragraph, we can go back to our field_widget_entity_reference_paragraphs_form_alter and add:

$parents = ['subform', 'field_decision_content_token', 'widget', 0, 'value', '#default_value'];
$decision_token = NestedArray::getValue($element, $parents);
if($decision_token){
$plugin->loadDecisionByToken($decision_token);
}

loadDecisionByToken is a custom function I added to DecisionParagraph.php that looks like this:

public function loadDecisionByToken($token){
$new_decision = $this->getDecisionStorage()->loadDecisionFromToken($token);
$new_decision->setDecision($this->decisionStorage->getEntity()->getDecision());
$this->decisionStorage = $new_decision;
}

In essence, this takes the attached decision_token, loads the decision it represents out of the database and sets the the decisionStorage inside DecisionParagraph to that decision. By virtue of doing this, when ->buildConfigurationForm is later called, it gives us back the form that represents the segment set and reactions from the saved decision. Create, Read, Update… what about Delete?

When it comes to Paragraphs, delete is a fickle mistress. Because you can ‘Remove, Confirm Removal,’ ‘Add’ a Paragraph and then save the Node, Paragraphs must create a new paragraph which orphans the old paragraph. The bright minds behind Entity Reference Revisions have created a QueueWorker that finds these orphaned paragraphs and cleans them up, kind of like a garbage collector. At the time of this writing, Personalized Paragraphs does not implement something similar, and this is yet another area ripe for contribution. For example, if one saved a Node with a filled decision in a personalized paragraph then edited the node, remove/confirm removal/added a new personalized paragraph, filled out the decision and saved, both decisions would still be in the decision_content tables. Now if one deletes that Node, the current personalized paragraph’s decision will be deleted, but the old one will not, essentially orphaning that old decision. Here is how delete currently works:

function personalized_paragraphs_entity_predelete(EntityInterface $entity)
{
if ($entity instanceof Node) {
if ($fields = _has_personalized_paragraph($entity)) {
foreach ($fields as $field) {
$has_para = $entity->get($field)->referencedEntities();
if (!empty($has_para)) {
$has_token = !$has_para[0]->get('field_decision_content_token')->isEmpty();
if ($has_token) {
$token = $has_para[0]->get('field_decision_content_token')->getValue()[0]['value'];
_delete_decision_content($token);
}
}
}
}
}
}

In a hook_entity_predelete, we detect the deletion of a node with personalized paragraphs, iterate those paragraphs and delete the decision represented by the token currently attached to the paragraph. So we’ll get the current decision token, but not any old ones. Given that the only way to change the segment set of an existing decision is to ‘remove’ ‘confirm removal’ ‘add’, this will likely happen often. The consequence is that decision tables will grow larger than they need to be, but hopefully we, or an enterprising user, will create a fix for this in the near future.

Okay so we’ve handled adding the smart_content_block experience to any Node edit form with a personalized_paragraph. What about viewing a personalized_paragraph?

For viewing, we have the old tried and true hook_preprocess_hook to the rescue. We deploy a personalized_paragraphs_preprocess_field__entity_reference_revisions so our hook will run for every Paragraph; we quickly narrow to only those paragraph’s that reference personalized_paragraphs:

$parents = ['items', 0, 'content', '#paragraph'];
$para = NestedArray::getValue($variables, $parents);
if($para->bundle() == 'personalized_paragraph'){
...
}

Next, we attempt to get the decision token out of the decision_content_token field so we can pass it to DecisionParagraph::build():

if($para->bundle() == 'personalized_paragraph'){
if ($plugin = personalized_paragraphs_get_handler('personalized_paragraph')) {
$has_token = !$para->get('field_decision_content_token')->isEmpty();
if($has_token) {
$token = $para->get('field_decision_content_token')->getValue()[0]['value'];
$build = $plugin->build($token);
...
}

Where the build function looks like:

public function build($token) {
$this->loadDecisionByToken($token);
$decision = $this->getDecisionStorage()->getDecision();

$build = [
'#attributes' => ['data-smart-content-placeholder' => $decision->getPlaceholderId()],
'#markup' => ' ',
];

$build = $decision->attach($build);
return $build;
}</span>

Which is slightly modified from DecisionBlock::build(). We load the decision content that was attached to the personalized_paragraphs, then call the DecisionBase::attach() function on that decision. This passes control to a number of functions that create the magic inside smart_content. When attach() returns, we are given an array that smart_content.js will process to decide on and retrieve a winning Reaction. To complete the function:

$has_token = !$para->get('field_decision_content_token')->isEmpty();
if($has_token) {
$token = $para->get('field_decision_content_token')->getValue()[0]['value'];
$build = $plugin->build($token);
$has_attached = array_key_exists('#attached', $build);
if ($has_attached && !empty($build['#attached']['drupalSettings']['smartContent'])) {
$variables['items'][0]['content']['#attributes'] = $build['#attributes'];
$variables['items'][0]['content']['#attached'] = $build['#attached'];

$para_data = [
'token' => $token,
];
$has_name = !$para->get('field_machine_name')->isEmpty();
$name = $has_name ? $para->get('field_machine_name')->getValue()[0]['value'] : '';
$variables['items'][0]['content']['#attached']['drupalSettings']['decision_paragraphs'][$name] = $para_data;
} </span>

We get the $build array back from ->build and verify that it has the appropriate attachments to run smart content. If it doesn’t we log a statement demonstrating that something in the build function has failed. If it does, we attach the correct piece of the build array to our variables array. I want to focus in on this code block to complete the discussion:

$para_data = [
'token' => $token,
];
$has_name = !$para->get('field_machine_name')->isEmpty();
$name = $has_name ? $para->get('field_machine_name')->getValue()[0]['value'] : '';
$variables['items'][0]['content']['#attached']['drupalSettings']['decision_paragraphs'][$name] = $para_data;

This code block represents how my organization manages the front end of personalized paragraphs and I’ll admit it’s an assumption on how you, the user, might want to manage it. If you’ve been following along, you’ll have noticed that pesky ‘Machine Name’ field I attached to personalized paragraphs. Here is where it comes into play. We extract the passed name which should be unique to the page itself; that name and the decision_content_token is attached to drupalSettings so it is available to javascript files using drupalSettings. With the name and token available in javascript, one can now:

a.) Detect that the decision paragraph loaded (is the decision_paragraphs key in drupalSettings? Does it contain this unique machine name?) and if not, ensure a default experience loads,

b.) Run javascript functions that display the winning experience or the default experience.

Since our method for managing the front end is beyond the scope of how personalized paragraphs was built, I’ll discuss it more in the How To Use Personalized Paragraphs blog.

There’s one more function to discuss that gets called as the front end experience is being displayed and that is DisplayParagraphs::getResponse. When smart_content.js selects a winner, it runs some ajax which calls ReactionController which loads the winning Reaction and calls its ->getResponse method. I had to slightly modify this method from DisplayBlocks to deal with Paragraphs:

public function getResponse(PlaceholderDecisionInterface $decision) {
$response = new CacheableAjaxResponse();
$content = [];
// Load all the blocks that are a part of this reaction.
$paragraphs = $this->getParagraphs([]);
if (!empty($paragraphs)) {
// Build the render array for each block.
foreach ($paragraphs as $para_arr) {
$pg_lib_conn = $this->entityTypeManager->getStorage('paragraphs_library_item');
$para_lib_item = $pg_lib_conn->load($para_arr['id']);
$has_para = !$para_lib_item->get('paragraphs')->isEmpty();
if($has_para){
$para_id = $para_lib_item->get('paragraphs')->getValue();
$target_id = $para_id[0]['target_id'];
$target_revision_id = $para_id[0]['target_revision_id'];
$para = Paragraph::load($target_id);
$render_arr = $this->entityTypeManager->getViewBuilder('paragraph')->view($para);
$access = $para->access('view',$this->currentUser, TRUE);
$response->addCacheableDependency($access);
if ($access) {
$content[] = [
'content' => $render_arr
];
$response->addCacheableDependency($render_arr);
}
}

}
}
// Build and return the AJAX response.
$selector = '[data-smart-content-placeholder="' . $decision->getPlaceholderId() . '"]';
$response->addCommand(new ReplaceCommand($selector, $content));
return $response;
}</span>

Instead of getting the content from the BlockCollection configuration, to send back, I had to grab the id stored in the config values, which loads a Paragraphs Library Item. That item references a Paragraph, so I grab its ID and load the Paragraph. The render array is created off the loaded Paragraph and sent back to the ajax to be displayed.

Phew, somehow we’ve gotten to what feels like the end. I’m sure there is something I’m forgetting that I’ll need to add later. But if you’ve made it this far then you are a champion. I hope what the Smart Content folks have created and my little extension work for your use case and that this blog has made you aware of how things work much more quickly than just reading and debugging the code would.

If you have any questions you can find me @plamb on the Drupal slack chat, there is also a #personalized_paragraphs channel and a How To Use Personalized Paragraphs blog.

Mar 06 2021
Mar 06
Sean BGeek CulturePublished in

6 min read

Mar 6, 2021

Responsive images have always been a pain to configure properly. In Drupal you can create your breakpoints in your theme or module and use the Responsive Image module to set up different responsive image styles, defining which image style to use for a specific breakpoint. This takes quite some work and planning to set everything up, and maintaining all the image styles if changes need to be made is always a pain. Most of the sites I have built lately also have a fluid design. Since the images are defined for fixed breakpoints, this leads to a lot of the images being loaded for the user are still too big.

After struggling with this, we thought about how we could find a way to improve this. In HTML5 we can define a “srcset” attribute, which loads the correct image based on the browsers viewport. The default “src” contains a really small version of the image by default for better performance. Also notice the HTML5 “loading” attribute to enable lazy loading of images for even more optimization.

My pretty image

Since we were using media to add images to content, we experimented with having media view modes defined by aspect ratio, combined with a bunch of different image styles for the images in that specific aspect ratio. The media template could provide all the image styles with different widths for that image style, and use the “srcset” attribute to let the browser pick the best image. So we now got image styles for a 4:3 ratio and 16:9 ratio like:

  • responsive_4_3_50w
  • responsive_16_9_50w
  • responsive_4_3_150w
  • responsive_16_9150w
  • responsive_4_3_1450w
  • responsive_16_9_1450w

For images maintaining their original ratio we just use the width, like 50w, 150w, etc. The media template for our “16_9" view mode (media — image — 16-9.html.twig) now looked like this, using the “image_style“ filter of the Twig Tweak module to load the actual image URLs for image styles from the file:

{#
/**
* @file
* Default theme implementation to display an image.
*/
#}
{% set file = media.field_media_image.entity %}
{% set src = file.uri.value|image_url('responsive_16_9_50w') %}
{% set srcset = [
file.uri.value|image_style('responsive_16_9_150w') ~ ' 150w',
file.uri.value|image_style('responsive_16_9_350w') ~ ' 350w',
file.uri.value|image_style('responsive_16_9_550w') ~ ' 550w',
file.uri.value|image_style('responsive_16_9_950w') ~ ' 950w',
file.uri.value|image_style('responsive_16_9_1250w') ~ ' 1250w',
file.uri.value|image_style('responsive_16_9_1450w') ~ ' 1450w',
] %}
{{ media.field_media_image.alt }}

The first problem we noticed, was the “srcset” attribute uses the viewport width, not the width of the image container. This means when the viewport is 1400px and the image is shown in a column with a width of 200px, the image style with a width of 1400px is chosen by the browser. This was not giving us the result we were looking for. The only way to figure out the width of the container is via JavaScript, so we wrote a little script to figure out the available width for each image and load the correct image style using ResizeObserver. The ResizeObserver does not work in IE11, but this was not a requirement for our project. Besides, Drupal will also drop IE11 support in Drupal 10! To prevent the browser from initially loading the large images from the “srcset” attribute, we changed the “srcset” attribute to “data-srcset” and let the JavaScript handle the rest.

// Fetch all images containing a "data-srcset" attribute.
const images = context.querySelectorAll('img[data-srcset]');

// Create a ResizeObserver to update the image "src" attribute when its
// parent container resizes.
const observer = new ResizeObserver(entries => {
for (let entry of entries) {
const images = entry.target.querySelectorAll('img[data-srcset]');
images.forEach(image => {
const availableWidth = Math.floor(image.parentNode.clientWidth);
const attrWidth = image.getAttribute('width');
const sources = image.getAttribute('data-srcset').split(',');

// If the selected image is already bigger than the available width,
// we do not update the image.
if (attrWidth && attrWidth > availableWidth) {
return;
}

// Find the best matching source based on actual image space.
let source, responsiveImgPath, responsiveImgWidth;
for (source of sources) {
let array = source.split(' ');
responsiveImgPath = array[0];
responsiveImgWidth = array[1].slice(0, -1);
if (availableWidth < responsiveImgWidth) {
break;
}
}

// Update the "src" with the new image and also set the "width"
// attribute to easily check if we need a new image after resize.
image.setAttribute('src', responsiveImgPath);
image.setAttribute('width', responsiveImgWidth);
});
}
});

// Attach the ResizeObserver to the image containers.
images.forEach(image => {
observer.observe(image.parentNode);
});</span>

The second problem with this method was creating all the image styles we needed. This could be fixed with a form to automatically create all the image styles we needed for our aspect ratios. So we built the Easy Responsive Images module. The module needs a minimum and maximum width in combination with a preferred amount of pixels between each image style. An optional list of aspect ratio’s can also be defined. When the configuration is saved, the styles are automatically generated.

Now that we have the best possible images loaded based on the container, we can take one more step to improve the performance of our images. Using the Image Optimize module, we can create optimization pipelines that can automatically apply to images displayed via image styles. We chose to use JpegOptim and PngQuant supported via the Image Optimize Binaries module (the PreviousNext blog contains some more data on the module and results). If you can not install those binaries on your server, there is also a ImageAPI Optimize GD module.

Then there is also the ImageAPI Optimize WebP module.

WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster.

In our tests we found that for most images, WebP is about 30% — 50% smaller than jpg images. For png images it is even more. To easily load the WebP version of an image when a browser supports it, we created a “image_url” Twig filter in the Easy Responsive Images module, with added bonus support for external images via the Imagecache External module.

The final file for our media view mode using the JavaScript and the new Twig filter looks like this:

{#
/**
* @file
* Default theme implementation to display an image.
*/
#}
{{ attach_library('easy_responsive_images/resizer') }}
{% set file = media.field_media_image.entity %}
{% set src = file.uri.value|image_url('responsive_16_9_50w') %}
{% set srcset = [
file.uri.value|image_url('responsive_16_9_150w') ~ ' 150w',
file.uri.value|image_url('responsive_16_9_350w') ~ ' 350w',
file.uri.value|image_url('responsive_16_9_550w') ~ ' 550w',
file.uri.value|image_url('responsive_16_9_950w') ~ ' 950w',
file.uri.value|image_url('responsive_16_9_1250w') ~ ' 1250w',
file.uri.value|image_url('responsive_16_9_1450w') ~ ' 1450w',
] %}
{{ media.field_media_image.alt }}

The example uses the same aspect ratio for all defined widths, but technically that is not a requirement. Using a different aspect ratio for smaller/larger screens can still be used based on the requirements, although that would make the setup a bit more complex and would require more view modes for your media.

That’s about it. Some next steps could be adding a formatters for the modules and figuring out support for retina images (even though these would increase the image sizes).

Hope this helps anyone looking to improve and optimise the way they implement responsive images in Drupal.

Jan 13 2021
Jan 13

namespace Drupal\mymodule;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
use Drupal\Core\PathProcessor\OutboundPathProcessorInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Get the active subsite from the request based on URL.
*/
class SubsiteManager implements EventSubscriberInterface, OutboundPathProcessorInterface, InboundPathProcessorInterface {

const SUBSITE_ENTITY_TYPE = 'node';

const SUBSITE_PATH_FIELD = 'field_path';

/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;

/**
* The configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;

/**
* The active subsite.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $subsite;

/**
* Contruct the SubsiteManager.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The configuration factory.
*/
public function __construct(EntityTypeManagerInterface $entityTypeManager, ConfigFactoryInterface $configFactory) {
$this->entityTypeManager = $entityTypeManager;
$this->configFactory = $configFactory;
}

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
// Runs as soon as possible in the request but after
// LanguageRequestSubscriber (priority 255) because we want the language
// prefix to come before the subsite prefix.
KernelEvents::REQUEST => ['onKernelRequestSetSubsite', 254],
];
}

/**
* Get the active subsite from the request.
*
* @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
* The event to process.
*/
public function onKernelRequestSetSubsite(GetResponseEvent $event) {
// Set the default subsite as fallback.
if ($default_subsite = $this->getDefaultSubsite()) {
$this->setCurrentSubsite($default_subsite);
}

$request = $event->getRequest();
$request_path = urldecode(trim($request->getPathInfo(), '/'));

// First strip the language when it's processor is available. This is only
// the case when more than 1 language is installed.
if (\Drupal::hasService('path_processor_language')) {
$request_path = \Drupal::service('path_processor_language')->processInbound($request_path, $request);
}

// Get the first part of the path to check if it matches to a subsite page.
$path_args = array_filter(explode('/', $request_path));
$prefix = array_shift($path_args);

if (!$prefix) {
return;
}

// If the prefix matches to a subsite page, set it in the property.
$subsites = $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->loadByProperties([
'type' => 'subsite',
static::SUBSITE_PATH_FIELD => $prefix,
]);
$subsite = reset($subsites);
if ($subsite) {
$this->setCurrentSubsite($subsite);
}
}

/**
* {@inheritdoc}
*/
public function processInbound($path, Request $request) {
// Get the first part of the path to check if it matches to a subsite page.
$path_args = explode('/', trim($path, '/'));
$prefix = array_shift($path_args);

// If we don't have a prefix, or if the prefix is the only thing in the path,
// keep the current path as it is. This is a subsite homepage.
if (!$prefix || $path === '/' . $prefix) {
return $path;
}

// Only when dealing with a subsite page, change the request.
$subsites = $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->loadByProperties([
'type' => 'subsite',
static::SUBSITE_PATH_FIELD => $prefix,
]);
$subsite = reset($subsites);
if (!$subsite) {
return $path;
}

return '/' . implode('/', $path_args);
}

/**
* {@inheritdoc}
*/
public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) {
if (!empty($options['subsite'])) {
$subsite = $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->load($options['subsite']);
$options['prefix'] = $this->addPrefix($options['prefix'], $subsite->get(static::SUBSITE_PATH_FIELD)->value);
}
$subsite = $this->getCurrentSubsite();
if ($subsite && empty($options['subsite']) && !$this->isDefaultSubsite() && $path !== '/' . $subsite->get(static::SUBSITE_PATH_FIELD)->value) {
$options['subsite'] = $subsite->id();
$options['prefix'] = $this->addPrefix($options['prefix'], $subsite->get(static::SUBSITE_PATH_FIELD)->value);
}
return $path;
}

/**
* Create the updated path prefix.
*
* @param string $existing_prefix
* The existing path prefix.
* @param string $subsite_prefix
* The Subsite path prefix.
*
* @return string
* The combined path prefix.
*/
protected function addPrefix($existing_prefix, $subsite_prefix) {
$existing_prefix = trim($existing_prefix, '/');
$subsite_prefix = trim($subsite_prefix, '/');
$combined_prefixes = array_filter([$existing_prefix, $subsite_prefix]);
$prefix = implode('/', $combined_prefixes) . '/';
return $prefix !== '/' ? $prefix : '';
}

/**
* Set the current subsite.
*
* @param \Drupal\Core\Entity\EntityInterface $subsite
* The current subsite.
*/
public function setCurrentSubsite(EntityInterface $subsite) {
$this->subsite = $subsite;
}

/**
* Get the current subsite.
*
* @return \Drupal\Core\Entity\EntityInterface
* The current subsite.
*/
public function getCurrentSubsite() {
return $this->subsite;
}

/**
* Get the default subsite.
*/
public function getDefaultSubsite() {
// Fetch a default subsite based on a config page.
$default_subsite_id = $this->configFactory->get('mymodule.settings')->get('default');
return $default_subsite_id ? $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->load($default_subsite_id) : NULL;
}

/**
* Check if the current subsite is the default subsite.
*/
public function isDefaultSubsite() {
return $this->subsite && $this->getDefaultSubsite()->id() === $this->subsite->id();
}

}</span>

Sep 22 2020
Sep 22
Pierce Lamb12 min read

Sep 22, 2020

Smart Content is a module for Drupal which enables personalized content selection for anonymous and authenticated users. The module supplies the UI and logic for creating and making these selections as well as some simple browser based conditions to test, but Smart Content by itself does not provide the data needed to support them. However, there are a couple of modules in its ecosystem that support 3rd party data providers, for e.g. Demandbase and FunnelEnvy. The idea here is if your site is already using one of these data providers to record data about anonymous users, that data can be used to deliver personalized content within Smart Content. Recently, I built a connector for Marketo RTP to Smart Content; I will update this blog with a link once it is a public module. For now, however, I believe detailing how I did it can help others connect Smart Content to any 3rd party marketing API.

The entry point is first understanding what a Response from the Marketing API looks like. For example, in FunnelEnvy, there are two fundamental options: matching the ID of an Audience, or matching the ID of a Variation. In DemandBase, there are myriad dimensions in the response. In Marketo RTP, we have 6 dimensions with a number of sub-dimensions. Either way, this Response needs to be understood so we can start representing it inside Smart Content. One way to look at this response is to query the marketing API in your browser console. For example, in inspector -> console, for RTP, I would type: rtp(‘get’, ‘visitor’, function(data){console.log(data)}); and observe the results. We’ll take a step back here to discuss setting up Smart Content before continuing.

The entry point of Smart Content (SC) is the Segment Set. Administering Segment Sets is found in the Structure -> Smart Content -> Manage Segment Sets menu (once SC is installed). A Segment Set represents some generalized way you want to segment anonymous users on your site. For example, you might title a Segment Set ‘Industry’ and then within the set, create Segments that correlate to industries like ‘banking’ or ‘manufacturing’. Once you’ve created the ‘Industry’ Segment Set, press the ‘edit’ button and you should be brought to a page where you can add Segments. This brings us to the next core piece of SC: the Condition.

You’ll notice that under a Segment you have the ability to create a list of conditions. You can select “If all” (AND) are true or “If any” (OR) are true, then the segment evaluates to true, otherwise, false. SC works by iterating through these segments and checking their conditions; once a segment’s condition(s) evaluate to true, a winner has been found and SC delivers a reaction (personalized content) based on the true segment. These conditions correlate exactly to the API Response data we discussed above. So, in code, we’ll need to create a condition that matches the API we’re using.

At this point I’ll assume that you’ve created a custom module in Drupal to represent your connector; in my case I’ve named it ‘smart_content_marketo_rtp.’ Within your module, create a ‘src’ folder and a ‘js’ folder. Inside the src folder create a ‘Plugin’ folder and inside that folder a ‘Derivative’ folder and a ‘smart_content’ folder. In the ‘smart_content’ folder we’ll have a ‘Condition’ folder, and inside that a ‘Group’ folder and an option ‘Type’ folder. The end result should look like this:

The first piece of code we’ll add to this is a new PHP Class in /smart_content/ to represent our new condition. In my case I titled it ‘MarketoCondition.php’. In addition to that, we also need to add the .libraries.yml file. Here we’ll configure the module’s JS files which interact with Smart Content’s backend. Since we’re creating a new condition, we’ll follow SC’s naming conventions. In the library.yml file add your version of this config:

condition.marketo_rtp:
header: true
version: 1.x
js:
js/condition.marketo_rtp.js: { }
dependencies:
- smart_content/storage
- smart_content/condition_type.standard

Note the filename you created under js:, you’ll need to also add a js file with this name under your js folder (e.g. condition.marketo_rtp.js). Okay, back to the MarketoCondition.php file.

Here is where we will define our new Condition. It will look like this:

namespace Drupal\smart_content_marketo_rtp\Plugin\smart_content\Condition;

use Drupal\smart_content\Condition\ConditionTypeConfigurableBase;

/**
* Provides a Marketo condition plugin.
*
* @SmartCondition(
* id = "marketo",
* label = @Translation("Marketo"),
* group = "marketo",
* deriver = "Drupal\smart_content_marketo_rtp\Plugin\Derivative\MarketoConditionDeriver"
* )
*/
class MarketoCondition extends ConditionTypeConfigurableBase {

/**
* {@inheritdoc}
*/
public function getLibraries() {
$libraries = array_unique(array_merge(
parent::getLibraries(),
[
'smart_content_marketo_rtp/condition.marketo_rtp'
]
)
);
return $libraries;
}

}</span>

Note the definitions in the comments. The syntax here must be preserved as Smart Content reads these entries and uses them internally. Note the filepath in the ‘deriver’ assignment; go ahead and create this PHP class in your ‘Derivative’ folder as well. Make sure to change the string in the getLibraries() method so it matches your module name and your JS file config definition in libraries.yml.

What this file is doing is defining a new Condition for Smart Content; the ‘id’ key defines how it is named when it’s passed in SC, the ‘label’ key defines how it will appear to an end user and the ‘deriver’ key points to a class that will define how SC should interpret all the dimensions in the API response we discussed earlier. In essence “what conditions should be available under the Marketo id?” Finally, overriding the getLibraries() function allows us to attach our custom JS file whenever our new Condition is used. That JS file will describe how to interact with the 3rd party API that powers our new Condition.

Next, let’s move to the deriver file defined in the comment. As shown, this file will be in src/Plugin/Derivative and must match the name you put in the comment exactly. This file can be largely isomorphic to other ConditionDeriver’s from Smart Content; a good example is the Demandbase Module. The one method we will care about in creating a custom connector is the getStaticFields() method. This is where the connector will map the marketing API’s dimensions to actual Smart Content types. If you’ve checked out the Demandbase link above, you’ll see that the three basic SC types are ‘boolean’, ‘number’ and ‘textfield.’ Hopefully the marketing API’s response you’re working with fits neatly into these. When I wrote the Marketo RTP connector, the response did not, and I had to write my own custom types. This explains where the types ‘arraytext’ and ‘arraynumber’ come from in the getStaticFields() method in this connector:

protected function getStaticFields() {
return [
'abm-code' => [
'label' => 'ABM Code',
'type' => 'arraynumber',
],
'abm-name' => [
'label' => 'ABM Name',
'type' => 'arraytext',
],
'category' => [
'label' => 'Category',
'type' => 'textfield',
],
'group' => [
'label' => 'Group',
'type' => 'textfield',
],
'industries' => [
'label' => 'Industry',
'type' => 'arraytext',
],
'isp' => [
'label' => 'Internet Service Provider',
'type' => 'boolean',
],
'location-country' => [
'label' => 'Country',
'type' => 'arraytext',
],
'location-city' => [
'label' => 'City',
'type' => 'arraytext',
],
'location-state' => [
'label' => 'State',
'type' => 'arraytext',
],
'matchedSegments-name' => [
'label' => 'Segment Name',
'type' => 'arraytext',
],
'matchedSegments-code' => [
'label' => 'Segment ID',
'type' => 'arraynumber',
],
'org' => [
'label' => 'Organization',
'type' => 'textfield',
]
];
}

This will look confusing at first, but if you followed the link above about RTP’s 6 dimensions you’ll see that the getStaticFields array members match exactly to these 6 dimensions. I’ve introduced a convention here for any dimension that contains a keyed array: I’ve used a ‘-’ to separate the dimension itself from the nested key. For example ‘abm-code’ and ‘abm-name.’ This dash will be necessary later when we parse out a nested key from a condition. Note that the ‘label’ key will designate the string that users of your connector see when they create a new Condition.

At this point, if you’ve created your new Condition and your Deriver files, we have one more file to add before seeing our new Condition inside Smart Content. Under the ‘Group’ folder create a new PHP class titled after your marketing API, for e.g. mine is simply named ‘Marketo.php.’ This file groups all of the new conditions you defined in your ConditionDeriver under one name. It is a very simple file:

namespace Drupal\smart_content_marketo_rtp\Plugin\smart_content\Condition\Group;

use Drupal\smart_content\Condition\Group\ConditionGroupBase;

/**
* Provides a condition group for Marketo conditions.
*
* @SmartConditionGroup(
* id = "marketo",
* label = @Translation("Marketo")
* )
*/
class Marketo extends ConditionGroupBase {

}</span>

The ‘id’ field in the comment links it to MarketoCondition.php and the ‘label’ field defines what users will see as a grouping when they create a new Condition.

At this point, if you did not create any new types in the getStaticFields() method, we can edit our Industry Segment Set, flush caches, and then press the ‘Select a condition’ drop down. You should now be able to scroll through this list and see the new grouping and the dimensions you defined in your Deriver file. If they do not appear, then one of the steps above was done incorrectly.

If you did not create any new Types, you can skip this next section. In the getStaticFields method I pasted above, you can see two new Types, ‘arraynumber’ and ‘arraytext.’ To define these new types, we’ll create two new PHP classes in the src/smart_content/Condition/Type folder: ‘ArrayNumber.php’ and ‘ArrayText.php.’ Since these two new types are depending on a more primitive type (textfield or number), I can simply extend these more primitive types. As such, my ArrayNumber.php file will look like:

namespace Drupal\smart_content_marketo_rtp\Plugin\smart_content\Condition\Type;

use Drupal\smart_content\Plugin\smart_content\Condition\Type\Number;

/**
* Provides a 'arraynumber' ConditionType.
*
* @SmartConditionType(
* id = "arraynumber",
* label = @Translation("ArrayNumber"),
* )
*/
class ArrayNumber extends Number {

/**
* {@inheritdoc}
*/
public function getLibraries() {
return ['smart_content_marketo_rtp/condition_type.array'];
}

}</span>

And

namespace Drupal\smart_content_marketo_rtp\Plugin\smart_content\Condition\Type;

use Drupal\smart_content\Plugin\smart_content\Condition\Type\Textfield;

/**
* Provides a 'arraytext' ConditionType.
*
* @SmartConditionType(
* id = "arraytext",
* label = @Translation("ArrayText"),
* )
*/
class ArrayText extends Textfield {

/**
* {@inheritdoc}
*/
public function getLibraries() {
return ['smart_content_marketo_rtp/condition_type.array'];
}

}</span>

As you can see, since these new types will use all the same operators as their primitive types, the only method we need to override is the getLibraries() method which will pass a custom JS file for evaluating the truth values of our new Types. Note that the ‘id’ field MUST match the type name you gave in your Deriver file. Make sure to add that JS file in libraries.yml and your /js/ folder. The libraries.yml definition will look like this:

condition_type.array:
header: true
version: 1.x
js:
js/condition_type.array.js: { }
dependencies:
- core/drupal

I will not go into too much detail on condition_type.array.js as its unlikely most readers are defining a new Type. The key for this file is to define new functions that Smart Content will call when it encounters an ‘arraytext’ or ‘arraynumber’. These functions follow specific naming conventions, for e.g.:

Drupal.smartContent.plugin.ConditionType['type:arraytext'] = function (condition, value) {...}Drupal.smartContent.plugin.ConditionType['type:arraynumber'] = function (condition, value) {...}

Where condition is the Smart Content Condition represented in JSON and the value is the value discovered on the page for a given visitor. These functions must return boolean values. You can check out /modules/contrib/smart_content/js/condition_type.standard.js to get a better sense of these functions. Also you can message ‘plamb’ on the Drupal Slack.

If you’ve gotten this far, then we have one more file to populate. Way back at the beginning we created the file js/condition.marketo_rtp, but left it empty. In this file we will tell Smart Content what to do when it comes across a condition of type ‘Marketo.’ We’ll open this file with the following:

(function (Drupal) {

Drupal.smartContent = Drupal.smartContent || {};
Drupal.smartContent.plugin = Drupal.smartContent.plugin || {};
Drupal.smartContent.plugin.Field = Drupal.smartContent.plugin.Field || {};

...

}(Drupal));</span>

The primary function in this file will follow Smart Content’s naming conventions:

Drupal.smartContent.plugin.Field['marketo'] = function (condition) {...}

Note that the text ‘marketo’ matches the id we’ve been passing around in many other files. When Smart Content evaluates a field of grouping ‘marketo’ it will execute this function. The first job this function must perform is making sure we can access the Marketing API and get a Response. It does that by constructing a Promise so it can be done asynchronously and then it returns a resolution of that Promise to the Smart Content backend that contains the relevant value. As such, our functions skeleton will look like this:

Drupal.smartContent.plugin.Field['marketo'] = function (condition) {
Drupal.smartContent.marketo = new Promise((resolve, reject) =>
{...}

});
return Promise.resolve(Drupal.smartContent.marketo).then( (value) => {
...
});
}</span>

Let’s first take a look at what’s happening inside the Promise. Here we will be checking that we can call the API and resolve the Promise with its Response:

let attempts = 0;
const interval = setInterval(() => {
if (attempts < 200) {
if (typeof rtp === "function") {
clearInterval(interval);
rtp('get', 'visitor', function(data){
if(data.results) {
Drupal.smartContent.storage.setValue('marketo', data.results);
resolve(data.results);
} else {
resolve({})
}
});
}
}
else {
clearInterval(interval);
resolve({});
}
attempts++;
}, 10);

All this code is doing is running an interval function through 200 attempts of trying to resolve a given variable as a function. This structure closely models the other Smart Content modules, the main difference is that in others the function is waiting for a JS library to be available on the page vs a function resolution; this is merely an artefact of how Marketo RTP works. Once the rtp variable is recognized as a function, the code can successfully call it the way Marketo intends. The Response data can then be passed into the resolve statement to then be dealt with when the function returns.

You might wonder how the Drupal.smartContent.storage.setValue(‘marketo’, data.results); line made it into this code block. I omitted some earlier code for clarity that deals with this. Most Smart Content connectors use a browser’s Local Storage to store the results of the Marketing APIs response. This is because the metadata associated with a user in the API rarely changes. When a user returns to the site, instead of executing the setTimeout function and waiting for the rtp function to return, we can simply grab the stored values out of their browser which will always be faster.. Putting the Local Storage code back in, the code block will look like this:

Drupal.smartContent.plugin.Field['marketo'] = function (condition) {
let key = condition.field.pluginId.split(':')[1];
if(!Drupal.smartContent.hasOwnProperty('marketo')) {
if(!Drupal.smartContent.storage.isExpired('marketo')) {
let values = Drupal.smartContent.storage.getValue('marketo');
Drupal.smartContent.marketo = values;
}
else {
Drupal.smartContent.marketo = new Promise((resolve, reject) => {
//run setTimeout code
});
}

}

}</span>

So only if the ‘marketo’ key isn’t found in Local Storage is the setTimeout code run. You can get a better sense of what these methods are doing here. But wait, what is that let key = condition.field.pluginId.split(‘:’)[1]; line about? Key is used in the return statement which we will discuss next.

When Smart Content passes around condition information, its key is always the group name, ‘marketo’ appended with the key from the Deriver class that was selected in the condition. Common keys for the RTP connector would be ‘marketo:matchedSegments-code’ or ‘marketo:industries.’ The line we mentioned above, let key = condition.field.pluginId.split(‘:’)[1];, is thereby grabbing the string that occurs after the ‘:’ and storing it for use in the return statement. This key will be used to parse the Response structure that comes back from the marketing API. Now we can look at the full return statement:

return Promise.resolve(Drupal.smartContent.marketo).then( (value) => {
//All single value members and arrays containing values
if(value.hasOwnProperty(key)){
return value[key];
}else {
//All arrays of arrays
var is_array_type = marketo_testForArray(condition.field.type);
if(is_array_type) {
var refined_key = marketo_getCorrectKey(key);
if(value.hasOwnProperty(refined_key)) {
return value[refined_key];
}
}
}
return null;
});

Promise.resolve().then() guarantees the code in the then block will run when the promise resolves. Since we earlier stored the key the Smart Content condition cares about, we first check if the Marketo API’s returned value simply contains that key. If so we return the value at that key back to Smart Content; simple enough. However, if the key in question is one of our custom array types, we need to detect that, split the main key from the nested key and return the correct value. The two functions called in the else block provide that functionality.

With these pieces in place, we can now test our new connector.

At this point, we would login to whichever marketing platform we’re connecting to and create a new test segment to test against our connector. In Marketo, this means going to Web Personalization -> Segments -> Create New Segment. Every Marketing platform is a bit different, but many of them have the option of creating a segment based on a url parameter which is easy for local testing. In Marketo we would add a ‘Behavioral -> Include Pages’ segment and define a URL for testing, e.g. /?test=yes. Under ‘domains’ we’d make sure our is selected and hit save. With Marketo, we can hover the segment we just created and get the ID. This is the value Smart Content will be matching against when our connector runs.

If we then open a fresh incognito window (remember the Local Storage discussion earlier?) and load /?test=yes we should be matched into the Marketo Segment. We can verify this by opening the console and running rtp(‘get’, ‘visitor’, function(data){console.log(data)}); again. Expanding the return structure should show our ID in ‘matchedSegments.’

To see this matching in a decision block, we would go back to Drupal and load Structure -> Smart Content -> Manage Segment Sets. If you created a Segment Set earlier, use that one or create a new one. For Marketo we will either create or re-use a condition and select Segment ID(Marketo) and paste the segment ID we just copied. Once saved and with caches flushed, we can add a decision block in Structure -> Block Layout -> Place Block, select our new segment set, choose blocks and save. In a new incognito window, we would load /?test=yes and see whatever block we chose.

If you have any questions you can find me @plamb on the Drupal slack chat.

May 15 2020
May 15
Pierce Lamb12 min read

May 15, 2020

This is Part 2 in a two part series where we detail how to create custom URLs for Drupal Views Exposed Filters. Part 1 covers how we create/update and delete these URLs. Part 2 covers how to load and process these URLs

If you have any questions you can find me @plamb on the Drupal slack chat.

Now that we have the original Exposed Filter paths correlated to custom paths in the path_alias table, let’s look at how to load them in the View and make sure they’re loading the right page when clicked on.

When I first worked on this problem, I was using a contrib module for Views called ‘Better Exposed Filters’ (BEF). I made this choice because I wanted to expose the filters as links (verses a

May 15 2020
May 15
Pierce Lamb8 min read

May 15, 2020

Part 1 covers when and how to generate custom URLs, Part 2 covers how to load and process these URLs.

If you have any questions you can find me @plamb on the Drupal slack chat.

A core feature of Drupal is the View. Views are pages that list content on a Drupal website. Despite that simple-sounding description, Views can be complex, especially for beginners. One feature we commonly want on a content listing page is the ability for an end user to filter the displayed content dynamically. For example, a car website might display all cars in a database on a listing page and an end-user might want to click an exposed filter called ‘Tesla’ to show only the Tesla models. Drupal provides this functionality out-of-the-box. Exposed filters in Drupal function by attaching querying parameters to the base URL of the View which the backend can use to appropriately filter the content. For example, if I have a View with the path /analyst-relations that displays content from large technology analysts, one exposed filter might be a link with the title Gartner. The path attached to the Gartner link will look like /analyst-relations?related_firm=5467. This query parameter, ?related_firm=5467, provides all the information Drupal needs to appropriately filter content. However, it is not a very nice-looking, descriptive URL. Ideally the link associated with the Gartner filter is something like /analyst-relations/firm/gartner.

I should note now that I am not a SEO expert and I don’t know for certain if custom exposed filter links will affect ranking in search engines. However, when I click a link like /analyst-relations/firm/gartner I have a much better idea of what information will be contained on that page than if I click /analyst-relations?related_firm=5467. Since serving these URLs does not have a high performance cost and they provide a more user-friendly experience, I believe that is reason enough to serve them.

Our goal is to replace all default exposed filter links with custom, descriptive URLs. The first question is, how do we create the custom URLs programmatically? Each URL will need to be unique and based on the content(s) it is related to. One option would be to do this dynamically as a page with exposed filter links is being loaded. Another option is to generate and store the custom URL whenever the relevant content is created/updated/deleted. I preferred the second option as it feels safer, more performant, and Drupal 8/9 comes with the path_alias module which I believe fits this task. I’ll note that this decision is definitely up for debate.

Okay, so we’re going to generate these custom URLs at CRUD time for relevant content(s). The quickest way to do that is, in a custom module, utilizing hook_entity_insert, hook_entity_update, and hook_entity_delete. From a technical debt perspective there may be a better way to do this, for e.g. by extending Entity classes, but these hooks will get you to a proof-of-concept the quickest. Every time any Entity is created, updated or deleted, these hooks are going to fire. If our custom module is called custom_urls, in our custom_urls.module file we would have:

/**
* Implements hook_entity_insert()
*/
function custom_urls_entity_insert(Drupal\Core\Entity\EntityInterface $entity){
_create_or_update_path_alias($entity);
}
/**
* Implements hook_entity_update()
*/
function custom_urls_entity_update(Drupal\Core\Entity\EntityInterface $entity){
_create_or_update_path_alias($entity);
}
/**
* Implements hook_entity_delete()
*/
function custom_urls_entity_delete(Drupal\Core\Entity\EntityInterface $entity){
_delete_path_alias($entity)
}

Inside of _create_or_update_path_alias and _delete_path_alias the first thing we’ll do is narrow down to only the entities we care about. That function will be called: _is_relevant_entity. Exposed Filters are often based on TaxonomyTerms or specific Entity bundles. For our example, inside _is_relevant_entity we will narrow to only the Terms and Entity Bundle we care about:

function _is_relevant_entity(Drupal\Core\Entity\EntityInterface $entity){
$entity_arr = [
'boolean' => FALSE,
'old_path' => '',
'new_path' => ''
];
$maybe_term = $entity instanceof Drupal\taxonomy\Entity\Term;
if($maybe_term){
...
} elseif ($entity->bundle() == 'product') {
....
}
return $entity_arr;
}

$entity_arr will be used to carry information about if the Entity is relevant, what the generated exposed filter path is and what the custom URL will be. If you follow the control structure you can see we’re going to use it to determine what the boolean value should be and for our example we care about Terms and Entities of type product. In our proof-of-concept, it would look something like this:

function _is_relevant_entity(Drupal\Core\Entity\EntityInterface $entity){
$entity_arr = [
'boolean' => FALSE,
'old_path' => '/analyst-relations',
'new_path' => ''
];
$maybe_term = $entity instanceof Drupal\taxonomy\Entity\Term;
if($maybe_term){
$relevant_taxonomies = [
'related_topics' => '/topic?related_topic=',
'related_companies' => '?related_firm='
];
$taxonomy_name = $entity->bundle();
$entity_arr['boolean'] = in_array($taxonomy_name, array_keys($relevant_taxonomies));
$entity_arr['old_path'] = $entity_arr['old_path'].$relevant_taxonomies[$taxonomy_name].$entity->id();
} elseif ($entity->bundle() == 'product') {
$entity_arr['boolean'] = TRUE;
$entity_arr['old_path'] = $entity_arr['old_path'].'/product?related_product='.$entity->id();
}
return $entity_arr;
}

As you can see, to get to a POC, I’ve done a lot of hardcoding here. In a fully general solution and safer solution, we’d load the View and get the old_path and the values in $relevant_taxonomies that way. However, via hardcoding I’ve generated the exact same paths that the View will create, for e.g. /analyst-relations?related_firm=5467. Note that if you don’t generalize this and the query keys or path in the View change (they are customizable) this will stop working.

Okay, so back to our _create_or_update_path_alias function. The beginning will look something like this:

function _create_or_update_path_alias($entity){
$raw_entity_arr = _is_relevant_entity($entity);
if($raw_entity_arr['boolean']){
//Update the path alias with the new URL
$clean_entity_arr = _build_custom_url($entity, $raw_entity_arr);

We use the boolean key to make sure we have an Entity we care about. Next we generate the custom url in _build_custom_url. That function will look like this:

function _get_url_from_regex($title){
$replace_whitespace = preg_replace('/\s+/', '-',$title);
$new_path_caboose = preg_replace('/[^a-zA-Z.-]/','',$replace_whitespace);
return $new_path_caboose;
}
function _build_custom_url($entity, $entity_arr){
$maybe_product = $entity->bundle() == 'product';
$raw_entity_url = $entity->url();
$entity_url_arr = explode('/',$raw_entity_url);
if($maybe_product) {
//It's a product Node
$new_path_train = '/analyst-relations/product/';
if(array_key_exists(2, $entity_url_arr)){
$new_path_caboose = $entity_url_arr[2];
} else {
$new_path_caboose = _get_url_from_regex($entity->label());
}
} else {
//It's a taxonomy term
$old_path = $entity_arr['old_path'];
$maybe_firm = strpos($old_path,'firm') !== FALSE;
if($maybe_firm){
//Firm filter
$new_path_train = '/analyst-relations/firm/';
} else {
//Topic filter
$new_path_train = '/analyst-relations/topic/';
}
if(count($entity_url_arr) > 1 && $entity_url_arr[1] !== 'taxonomy'){
$new_path_caboose = $entity_url_arr[1];
} else {
$new_path_caboose = _get_url_from_regex($entity->label());
}
}
$new_path = $new_path_train.strtolower($new_path_caboose);
$entity_arr['new_path'] = $new_path;
return $entity_arr;
}

In this function, we attempt to create the custom URL from the $entity->url() attached to Product’s and Taxonomy Terms. If we’re unable to, we pass the $entity-label() through some regexs. I’ve split the regexs into two inside _get_url_from_regex to make it easier to understand what is going on. We take the Entities label and replace any whitespace in it with a dash. We take this string and remove any non alphabetic character out of it. This produces strings that should work as the end (the caboose) of the new path and they replace the id number out of the old path. Then, whether we have a product or appropriate taxonomy term, we create the first part (the train) of the custom URL. Again this has been hardcoded for alacrity, but like above, in a general solution we’d load the View and create these. Like above if the View’s path changes this will stop working.

Okay so now we have an array that verifies we have a correct Entity, its old exposed filter path and the new custom path we want it to have. Now we are going to use the entityTypeManager() to query the path_alias table. Let’s view some more of the _create_or_update_path_alias function:

function _create_or_update_path_alias($entity){
$raw_entity_arr = _is_relevant_entity($entity);
if($raw_entity_arr['boolean']){
//Update the path alias with the new URL
$clean_entity_arr = _build_custom_url($entity, $raw_entity_arr);
$old_path = $clean_entity_arr['old_path'];
$new_path = $clean_entity_arr['new_path'];
$path_alias_conn = \Drupal::entityTypeManager()->getStorage('path_alias');
$new_path_already_exists = $path_alias_conn->loadByProperties(['alias' => $new_path]);
if(empty($new_path_already_exists)) {
$maybe_path_alias = $path_alias_conn->loadByProperties(['path' => $old_path]);
if (empty($maybe_path_alias)) {
//Create path alias
} else if (count($maybe_path_alias) == 1) {
//Update path alias
} else {
//We've somehow returned more than one result for the old path. Something is wrong
\Drupal::logger('custom_urls')->notice("The path: " . $old_path . ", is returning more than one result in path_alias");
}
} else {
\Drupal::logger('custom_urls')->notice("The generated path: " . $new_path . ", already exists in path_alias. An entity with an identical title was likely created");
}
}
}

So we get the connection to the path_alias table. First we test if the $new_path (the custom URL) already exists there. If it does we don’t do anything and send a message to the logger so we’re aware that the current Entity is trying to create a custom URL that already exists. Then we check if the $old_path (the generated exposed filter path) is already in the path_alias table (note that because they contain the entity’s id, they should only ever conflict on the rare chance, say, a Node and a Term on the same view have the same ID). If it does not we create the new path_alias entry using the $old_path and $new_path; else if it comes back with 1 result than we have an update and we update the $new_path; else we’ve somehow returned more than one result for the $old_path and we notify the logger. Here is the function completely filled out:

function _create_or_update_path_alias($entity){
$raw_entity_arr = _is_relevant_entity($entity);
if($raw_entity_arr['boolean']){
//Update the path alias with the new URL
$clean_entity_arr = _build_custom_url($entity, $raw_entity_arr);
$old_path = $clean_entity_arr['old_path'];
$new_path = $clean_entity_arr['new_path'];
$path_alias_conn = \Drupal::entityTypeManager()->getStorage('path_alias');
$new_path_already_exists = $path_alias_conn->loadByProperties(['alias' => $new_path]);
if(empty($new_path_already_exists)) {
$maybe_path_alias = $path_alias_conn->loadByProperties(['path' => $old_path]);
if (empty($maybe_path_alias)) {
//Create path alias
$new_path_ent = $path_alias_conn->create([
'path' => $old_path,
'alias' => $new_path,
'langcode' => \Drupal::languageManager()->getCurrentLanguage()->getId()
]);
$new_path_ent->save();
//Add new URL to cache
_cache_fancy_url($old_path,$new_path);
} else if (count($maybe_path_alias) == 1) {
//Update path alias
$path_alias_obj = reset($maybe_path_alias);
$path_alias_obj->set('alias', $new_path);
$path_alias_obj->save();
//Drop old URL from cache and add new one
_cache_fancy_url($old_path,$new_path);
} else {
//We've somehow returned more than one result for the old path. Something is wrong
\Drupal::logger('custom_urls')->notice("The path: " . $old_path . ", is returning more than one result in path_alias");
}
} else {
\Drupal::logger('custom_urls')->notice("The generated path: " . $new_path . ", already exists in path_alias. An entity with an identical title was likely created");
}
}
}

But wait, another function snuck in there: _cache_fancy_url($old_path,$new_path). In Part 2 of this series, we will look at how to load and process the custom urls; doing this from the cache is definitely the fastest way to do that, so we create/modify cache entries here. For clarity, I will show that function here:

function _cache_fancy_url($old_path, $new_path){
$default_cache = \Drupal::cache();
$old_path_result = $default_cache->get($old_path);
if($old_path_result !== FALSE) {
//Old path in cache, likely a Term or Product has been modified
//delete the old entry
$default_cache->delete($old_path);
}
//Add the new entry $default_cache->set($old_path,$new_path,Drupal\Core\Cache\CacheBackendInterface::CACHE_PERMANENT);
}

Caching here isn’t too important because when we load the custom urls, if they aren’t in the cache (perhaps, after a cache flush) we will set them there, but for what extra performance a 5 line function imparts it is worth it.

The delete hook is highly similar to the first two. I’ll paste it here and I imagine if you’ve read the above not much explanation is needed:

function custom_urls_entity_delete(Drupal\Core\Entity\EntityInterface $entity){
$raw_entity_arr = _is_relevant_entity($entity);
if($raw_entity_arr['boolean']){
//delete the associated path alias
$old_path = $raw_entity_arr['old_path'];
$path_alias_conn = \Drupal::entityTypeManager()->getStorage('path_alias');
$maybe_path_alias = $path_alias_conn->loadByProperties(['path' => $old_path]);
if(count($maybe_path_alias) == 1){
$path_alias_conn->delete($maybe_path_alias);
_delete_from_cache($maybe_path_alias);
} else {
\Drupal::logger('custom_urls')
->notice("The path: ".$old_path.", was set to delete from path_alias, but it returned ".count($maybe_path_alias)." results");
}
}
}

So now every time an Entity we care about in our View with exposed filters is created/updated/deleted we are also creating/updating/deleting and caching its associated custom URL. I prefer this way of creating the custom URLs versus creating them dynamically when the page loads as I feel that executing this extra code at entity CRUD time is more performant than at page load. While I know path_alias was intended for URLs like /node/1, I feel that this usage of the path_alias table matches its general intention: to provide nice aliases for non-nice paths.

We are one big step closer to custom URLs on a View with exposed filters, check out Part 2 to see how to load and process these custom URLs.

Apr 04 2019
Apr 04
Julia GutierrezMassachusetts Digital ServicePublished in

4 min read

Apr 4, 2019

DrupalCon2019 is heading to Seattle this year and there’s no shortage of exciting sessions and great networking events on this year’s schedule. We can’t wait to hear from some of the experts out in the Drupalverse next week, and we wanted to share with you a few of the sessions we’re most excited about.

Adam is looking forward to:

Government Summit on Monday, April 8th

“I’m looking forward to hearing what other digital offices are doing to improve constituents’ interactions with government so that we can bring some of their insights to the work our agencies are doing. I’m also excited to present on some of the civic tech projects we have been doing at MassGovDigital so that we can get feedback and new ideas from our peers.”

Bryan is looking forward to:

1. Introduction to Decoupled Drupal with Gatsby and React

Time: Wednesday, April 10th from 1:45 pm to 2:15 pm

Room: 6B | Level 6

“We’re using Gatsby and React today on to power Search.mass.gov and the state’s budget website, and Drupal for Mass.gov. Can’t wait to learn about Decoupled Drupal with Gatsby. I wonder if this could be the right recipe to help us make the leap!”

2. Why Will JSON API go into Core?

Time: Wednesday, April 10th from 2:30 pm to 3:00 pm

Room: 612 | Level 6

“Making data available in machine-readable formats via web services is critical to open data and to publish-once / single-source-of-truth editorial workflows. I’m grateful to Wim Leers and Mateu Aguilo Bosch for their important thought leadership and contributions in this space, and eager to learn how Mass.gov can best maximize our use of JSON API moving forward.”

I (Julia) am looking forward to:

1. Personalizing the Teach for America applicant journey

Time: Wednesday, April 10th from 1:00 pm to 1:30 pm

Room: 607 | Level 6

“I am really interested in learning from Teach for America on how they implemented personalization and integrated across applications to bring applicants a consistent look, feel, and experience when applying for a Teach for America position. We have created Mayflower, Massachusetts government’s design system, and we want to learn what a single sign-on for different government services might look like and how we might use personalization to improve the experience constituents have when interacting with Massachusetts government digitally. ”

2. Devsigners and Unicorns

Time: Wednesday, April 10th from 4:00 pm to 4:30 pm

Room: 612 | Level 6

“I’m hoping to hear if Chris Strahl has any ‘best-practices’ and ways for project managers to leverage the unique multi-skill abilities that Devsigners and unicorns possess while continuing to encourage a balanced workload for their team. This balancing act could lead towards better development and design products for Massachusetts constituents and I’d love to make that happen with his advice!”

Melissa is looking forward to:

1. DevOps: Why, How, and What

Time: Wednesday, April 10th from 1:45 pm to 2:15 pm

Room: 602–604 | Level 6

“Rob Bayliss and Kelly Albrecht will use a survey they released as well as some other important approaches to elaborate on why DevOps is so crucial to technological strategy. I took the survey back in November of 2018, and I want to see what those results from the survey. This presentation will help me identify if any changes should be made in our process to better serve constituents from these results.”

2. Advanced Automated Visual Testing

Time: Thursday, April 11th from 2:30 pm to 3:00 pm

Room: 608 | Level 6

“In this session Shweta Sharma will speak to what visual testings tools are currently out there and a comparison of the tools. I am excited to gain more insight into the automated visual testing in faster and quicker releases so we can identify any gotchas and improve our releases for Mass.gov users.

P.S. Watch a presentation I gave at this year’s NerdSummit in Boston, and stay tuned for a blog post on some automation tools we used at MassGovDigital coming out soon!”

We hope to see old friends and make new ones at DrupalCon2019, so be sure to say hi to Bryan, Adam, Melissa, Lisa, Moshe, or me when you see us. We will be at booth 321 (across from the VIP lounge) on Thursday giving interviews and chatting about technology in Massachusetts, we hope you’ll stop by!

Interested in a career in civic tech? Find job openings at Digital Services.
Follow us on Twitter | Collaborate with us on GitHub | Visit our site

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web