[Drupal 8] Custom module featuring field formatter & twig template

The following is a selection of code samples from a Drupal 8 custom module I created for managing CDN image ID values.

A custom field widget is used to save and manage CDN image information, and a custom field formatter works in conjunction with a simple twig template to render the images to html.

Completion Date
Drupal 8 Logo
Platform(s)/Language(s)
Code Snippet
# cdn_manager.info.yml

name: CDN Manager
description: Handles CDN image data
package: Custom

type: module
core: 8.x
core_version_requirement: ^8 || ^9
<?php
 
/**
 * @file
 * cdn_manager.module
 *
 * Module: CDN Management Module.
 */
 
use Drupal\cdn_manager\CdnImg;
 
/**
 * Implements hook_theme().
 */
function cdn_manager_theme() {
  return [
    'cdn_image' => [
      'base hook' => 'image',
      'variables' => ['img' => []],
    ],
  ];
}
<!-- cdn-image.html.twig -->
 
<figure class="image">
  <img src="{{ img.base_url }}/{{ img.cdn_id }}/640x480px/{{ img.filename }}?{{ img.url_query }}" alt="{{ img.alt_tag }}"/>
</figure>
<?php
 
namespace Drupal\cdn_manager\Plugin\Field\FieldType;
 
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\TypedData\DataDefinition;
 
/**
 * Plugin implementation of the 'cdn_img' field type.
 *
 * @FieldType(
 *   id = "cdn_img",
 *   label = @Translation("CDN Manager field"),
 *   description = @Translation("An entity field containing CDN image content"),
 *   category = @Translation("Custom"),
 *   default_formatter = "cdn_img_formatter",
 *   default_widget = "cdn_img_widget",
 * )
 */
class CdnImg extends FieldItemBase {
 
  /**
   * {@inheritdoc}
   */
  public static function schema(FieldStorageDefinitionInterface $field_definition) {
    return [
      // Columns contains the values that the field will store.
      'columns' => [
        // List the values that the field will save. This
        // field will only save a single value, 'value'.
        'cdn_id' => [
          'type' => 'varchar',
          'size' => 'normal',
          'length' => 255,
          'not null' => FALSE,
        ],
        'filename' => [
          'type' => 'varchar',
          'size' => 'normal',
          'length' => 255,
          'not null' => FALSE,
        ],
        'alt_tag' => [
          'type' => 'varchar',
          'size' => 'normal',
          'length' => 255,
          'not null' => FALSE,
        ],
      ],
    ];
  }
 
  /**
   * {@inheritdoc}
   */
  public function isEmpty() {
    $cdn_id = $this->get('cdn_id')->getValue();
    $filename = $this->get('filename')->getValue();
    $alt_tag = $this->get('alt_tag')->getValue();
    return (empty($cdn_id) &&
            empty($filename) &&
            empty($alt_tag));
  }
 
  /**
   * {@inheritdoc}
   */
  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    // Add our properties.
    $properties['cdn_id'] = DataDefinition::create('string')
      ->setLabel(t('CDN ID'));
    $properties['filename'] = DataDefinition::create('string')
      ->setLabel(t('Filename'));
    $properties['alt_tag'] = DataDefinition::create('string')
      ->setLabel(t('Alt Tag'));
    return $properties;
  }
 
}
<?php
 
namespace Drupal\cdn_manager\Plugin\Field\FieldWidget;
 
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
 
/**
 * Plugin implementation of the 'cdn_img' widget.
 *
 * @FieldWidget(
 *   id = "cdn_img_widget",
 *   label = @Translation("CDN Manager widget"),
 *   field_types = {
 *     "cdn_img"
 *   },
 * )
 */
class CdnImgWidget extends WidgetBase {
 
  /**
   * {@inheritdoc}
   */
  public function formElement(FieldItemListInterface $items,
                               $delta,
                               array $element,
                               array &$form,
                               FormStateInterface $form_state
                              ) {
    $element += [
      'cdn_id' => [
        '#type' => 'textfield',
        '#title' => $this->t('CDN ID'),
        '#default_value' => empty($items[$delta]->cdn_id) ? '' : $items[$delta]->cdn_id,
        '#size' => 60,
        '#maxlength' => 255,
      ],
      'filename' => [
        '#type' => 'textfield',
        '#title' => $this->t('Filename'),
        '#default_value' => empty($items[$delta]->filename) ? '' : $items[$delta]->filename,
        '#size' => 60,
        '#maxlength' => 255,
      ],
      'alt_tag' => [
        '#type' => 'textfield',
        '#title' => $this->t('Alt Tag'),
        '#default_value' => empty($items[$delta]->alt_tag) ? '' : $items[$delta]->alt_tag,
        '#size' => 60,
        '#maxlength' => 255,
        '#attributes' => ['class' => ['container-inline']],
      ],
      '#type' => 'fieldset',
    ];
    return $element;
  }
 
}
<?php
 
namespace Drupal\cdn_manager\Plugin\Field\FieldFormatter;
 
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\cdn_manager\CdnImg;
 
/**
 * Plugin implementation of the 'cdn_img' formatter.
 *
 * @FieldFormatter(
 *   id = "cdn_img_formatter",
 *   label = @Translation("CDN Manager Image"),
 *   field_types = {
 *     "cdn_img",
 *   },
 * )
 */
class CdnImgFormatter extends FormatterBase implements ContainerFactoryPluginInterface {
 
  /**
   * {@inheritdoc}
   */
  public function settingsSummary() {
    $summary = [];
    $summary[] = $this->t('Displays the CDN Manager item.');
    return $summary;
  }
 
  /**
   * {@inheritdoc}
   */
  public function viewElements(FieldItemListInterface $items, $langcode = NULL) {
    $elems = [];
 
    foreach ($items as $delta => $item) {
      // Render each element as a cdn_img array.
      $cdn_img = new CdnImg();
      $cdn_img->setImagePropertiesFromObject($item);
      $elems[$delta] = [
        '#theme' => 'cdn_image',
        '#img' => $cdn_img->getImageUrlComponents(),
      ];
    }
 
    return $elems;
  }
 
}
<?php
 
namespace Drupal\cdn_manager;
 
use Drupal\Core\Url;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Component\Utility\Xss;
 
/**
 * Provides a cdn image object for rendering images.
 */
class CdnImg {
 
  /**
   * CDN Base Url.
   *
   * @var string
   */
  protected static $baseUrl = '//embed.widencdn.net/img/accountname';
 
  /**
   * CDN User ID.
   *
   * @var string
   */
  protected static $cdnUserId = 'user_id';
 
  /**
   * CDN ID.
   *
   * @var string
   */
  protected $cdnId = '';
 
  /**
   * Filename.
   *
   * @var string
   */
  protected $filename = '';
 
  /**
   * Alt tag.
   *
   * @var string
   */
  protected $altTag = '';
 
  /**
   * URL Query Parameters.
   *
   * @var array
   */
  protected $urlQueryArray = [];
 
  /**
   * CDN Image item Constructor.
   *
   * @param string $cdn_id
   *   CDN ID.
   * @param string $filename
   *   File name.
   * @param string $alt_tag
   *   Alt tag.
   * @param string $intended_use
   *   Intended CDN use code for tracking usage analytics.
   */
  public function __construct($cdn_id = '', $filename = '', $alt_tag = '', $intended_use = '') {
    $this->cdnId = $cdn_id;
    $this->filename = $filename;
    $this->altTag = $alt_tag;
    $this->setUrlQueryArray($intended_use);
    if (func_num_args()) {
      $this->sanitizeInputs();
    }
  }
 
  /**
   * Gets any arbitrary static or dynamic property.
   *
   * @param string $property
   *   The property to retrieve.
   *
   * @return mixed
   *   The value for that property, or NULL if the property does not exist.
   */
  public function get($property) {
    $value = $this->{$property} ?? (self::${$property} ?? NULL);
    return $value;
  }
 
  /**
   * Sets a value to an arbitrary property.
   *
   * @param string $property
   *   The property to use for the value.
   * @param mixed $value
   *   The value to set.
   *
   * @return $this
   */
  public function set($property, $value) {
    if (property_exists($this, $property)) {
      $this->{$property} = $value;
    }
    return $this;
  }
 
  /**
   * Determines whether the cdn object contains any non-empty items.
   *
   * @return bool
   *   TRUE if cdn id, filename, and alt tag values are empty, FALSE otherwise.
   */
  public function isEmpty() {
    return (
      empty($this->get('cdnId')) &&
      empty($this->get('filename')) &&
      empty($this->get('altTag'))
    );
  }
 
  /**
   * Set CDN Image properties based on passed-in object.
   *
   * @param object $obj
   *   Object being passed should have the following properties:
   *   - cdn_id or cdnId.
   *   - filename.
   *   - alt_tag or altTag.
   * @param bool $not_avail
   *   Boolean indicating whether to insert default 'not-available' values if
   *   the cdn id or filename values are missing.
   */
  public function setImagePropertiesFromObject($obj, $not_avail = TRUE) {
    $this->cdnId = $obj->cdn_id ?? ($obj->cdnId ?? '');
    $this->filename = $obj->filename ?? '';
    $this->altTag = $obj->alt_tag ?? ($obj->altTag ?? '');
    $this->sanitizeInputs();
 
    if ($not_avail) {
      if ($this->isEmpty()) {
        // Only set the alt tag value to not-available if the other fields are
        // also empty.
        $this->altTag = $this->t('No photo available.');
      }
      if (empty($this->filename)) {
        $this->filename = 'photo-not-available.png';
      }
      if (empty($this->cdnId)) {
        $this->cdnId = 'notavail';
      }
    }
  }
 
  /**
   * Set CDN Image Properties for "No photo available".
   */
  public function setImageNotAvailable() {
    $this->cdnId = 'notavail';
    $this->filename = 'photo-not-available.png';
    $this->altTag = $this->t('No photo available.');
  }
 
  /**
   * Returns a render array for rendering the cdn image in Drupal 8.
   *
   * @param string $size
   *   Should be a text string, specifying "WIDTHxHEIGHT"
   *   e.g., 640x480, 120x120, 1024x768.
   *   $size set to a single number, e.g., '300' will specify the width, but not
   *   the height, and scale it appropriately.
   *   $size set to begin with an 'x' and with a single number will specify the
   *   height but not the width., e.g. ('x150').
   * @param string $scale
   *   This is for retina images, and can be any numeric value.
   *   e.g., an image requested at "460x520px@2x" will be delivered with
   *   final pixel dimensions of 920x1040 px.
   *
   * @return array
   *   D8 render array.
   */
  public function getRenderArray($size = '576x324', $scale = NULL) {
    /* return '<img src="' . $this->getImageUrl($size, $intended_use) . '"' .
     *   (empty($this->altTag) ? '' : '" alt="' . $this->altTag) . '">';
     */
    return [
      '#theme' => 'image',
      '#uri' => $this->getImageUrl($size, $scale),
      '#alt' => $this->altTag,
    ];
  }
 
  /**
   * Returns a properly formatted URL for the CDN image, given the pixel size.
   *
   * Required parameters are $cdn_img_array and $size.  The rest are optional.
   *
   * The 'intended use' parameter in the link is a code used to track how the
   *   images is being used in the CDN system.
   * To find out more about intended use:
   *   https://support.widencollective.com/support/solutions/articles/6000028390-how-do-i-collect-intended-use-
   *
   * More information:
   *   https://support.widencollective.com/support/solutions/articles/6000028387-can-i-customize-share-links-and-embed-codes-for-images-
   *
   * @param string $size
   *   Should be a text string, specifying "WIDTHxHEIGHT"
   *   e.g., 640x480, 120x120, 1024x768.
   *   $size set to a single number, e.g., '300' will specify the width, but not
   *   the height, and scale it appropriately.
   *   $size set to begin with an 'x' and with a single number will specify the
   *   height but not the width., e.g. ('x150').
   * @param string $scale
   *   This is for retina images, and can be any numeric value.
   *   e.g., an image requested at "460x520px@2x" will be delivered with
   *   final pixel dimensions of 920x1040 px.
   *
   * @return string
   *   Returns formatted image url.
   */
  public function getImageUrl($size = '576x324', $scale = NULL) {
    $output = FALSE;
 
    $px_size = $size . 'px';
    if (!empty($scale)) {
      $px_size .= '@' . $scale . 'x';
    }
 
    if (!empty($this->cdnId) && !empty($this->filename)) {
      $path = self::$baseUrl . '/' . $this->cdnId . '/' . $px_size . '/' .
        $this->filename;
 
      // GIF images require slightly different handling.
      $path = $this::correctGifs($path);
 
      $options = [
        'query' => $this->urlQueryArray,
      ];
 
      // Build external url object.
      $url = Url::fromUri($path, $options);
 
      $output = $url->toString();
    }
 
    return $output;
  }
 
  /**
   * Set url query parameters according to various preferences.
   *
   * @param string $intended_use
   *   Intended Widen CDN use code for tracking usage analytics.
   * @param int $quality
   *   Integer from 0 (lowest quality) to 100 (highest quality),
   *   default is 80.
   * @param bool $crop
   *   Do you want to crop the image?  Default is TRUE.
   * @param string $keep_area
   *   Specifies which area of the image you'd like to keep.
   *   "c" = center, "n" = north, "ne" = northeast, "e" = east,
   *   "se" = southeast, "s" = south, "sw" = southwest, "w" = west,
   *   "nw" = northwest.
   * @param string $background_color
   *   Hex value of the background color you want to use if and only if cropping
   *   is set to FALSE.
   */
  public function setUrlQueryArray($intended_use = '', $quality = 80, $crop = TRUE, $keep_area = 'c', $background_color = 'ffffff') {
    $query = [
      'u' => self::$cdnUserId,
      'k' => $keep_area,
    ];
    if (!empty($intended_use)) {
      $query['use'] = $intended_use;
    }
    if (!$crop) {
      $query['c'] = '0';
      $query['co'] = $background_color;
    }
    if ($quality <> 80) {
      $query['q'] = $quality;
    }
 
    $this->urlQueryArray = $query;
  }
 
  /**
   * Parses the url query array into a valid, rawurlencoded query string.
   *
   * @return string
   *   A rawurlencoded string which can be used as or appended to the URL query
   *   string.
   */
  public function getUrlQueryString() {
    return(UrlHelper::buildQuery($this->urlQueryArray));
  }
 
  /**
   * Helper function to sanitize all values of an object.
   */
  protected function sanitizeInputs() {
    // We're trimming values to make sure any stray spaces at the beginning or
    // end of the array values don't get included in the embedding code.
    // Per Atrium #19617.
    // @TODO - this should also happen on node save so that we store clean data in the first place.
    foreach (get_object_vars($this) as $key => $val) {
      if (!empty($val) && is_string($val)) {
        $this->$key = $this::sanitize($val);
      }
    }
  }
 
  /**
   * {@inheritdoc}
   */
  public static function sanitize($text, array $allowed_tags = []) {
    // Prevent cross-site-scripting (XSS) vulnerabilities.
    $text_clean = Xss::filter($text, $allowed_tags);
    // HTML attribute cannot contain quotes.
    $text_clean = str_replace('"', "&quot;", $text_clean);
    // Remove any stray spaces.
    $text_clean = trim($text_clean);
 
    return $text_clean;
  }
 
  /**
   * Correct CDN URLs for GIF images.
   *
   * This runs automatically in the getImageUrl() function.
   */
  private static function correctGifs($cdn_url) {
    // Correct URLs for GIF images.
    // From this: https://embed.widencdn.net/img/accountname/idvalue/576x324px/example_image.gif?u=at8tiu&use=idsla&k=c
    // To this: https://embed.widencdn.net/original/accountname/idvalue/example_image.gif?u=hqh5il&use=otpm1
    if (preg_match('/\.gif/i', $cdn_url)) {
      $cdn_url = preg_replace('/\/img\//i', '/original/', $cdn_url);
      $cdn_url = preg_replace('/\/(\d+)x(\d+)px/i', '', $cdn_url);
    }
    return $cdn_url;
  }
 
  /**
   * Returns an array of CDN image url components.
   *
   * Useful for constructing an image url in templates.
   *
   * @param string $size
   *   Should be a text string, specifying "WIDTHxHEIGHT"
   *   e.g., 640x480, 120x120, 1024x768.
   *   $size set to a single number, e.g., '300' will specify the width, but not
   *   the height, and scale it appropriately.
   *   $size set to begin with an 'x' and with a single number will specify the
   *   height but not the width., e.g. ('x150').
   *
   * @return array
   *   The array will look like:
   *   [
   *     'base_url'  => '',
   *     'cdn_id'    => '',
   *     'filename'  => '',
   *     'url_query' => [],
   *     'alt_tag'   => '',
   *   ]
   */
  public function getImageUrlComponents($size = '') {
    $arr = [
      'base_url' => self::$baseUrl,
      'cdn_id' => $this->cdnId,
      'filename' => $this->filename,
      'url_query' => $this->getUrlQueryString(),
      'alt_tag' => $this->altTag,
    ];
    if (!empty($size)) {
      $arr['size'] = $size . 'px';
    }
    return $arr;
  }
 
}

Drupal Association Individual Member      Drupal Association Individual Member      #DrupalCares Supporter      Acquia Certified Site Builder