You are here

Files.php in Advanced CSS/JS Aggregation 8.2

Given a filename calculate various hashes and gather meta data.


  'filesize' => filesize($file),
  'mtime' => mtime($file),
  'filename_hash' => Crypt::hashBase64($file),
  'content_hash' => Crypt::hashBase64($file_contents),
  'linecount' => $linecount,
  'data' => $file,
  'fileext' => $ext,
  ...

File

src/State/Files.php
View source
<?php

namespace Drupal\advagg\State;

use Drupal\Core\Asset\AssetDumperInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Component\Utility\Crypt;

/**
 * Provides AdvAgg with a file status state system using a key value store.
 */
class Files extends State {

  /**
   * A config object for the advagg configuration.
   *
   * @var \Drupal\Core\Config\Config
   */
  protected $config;

  /**
   * Module handler service.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * Save location for split files.
   *
   * @var string
   */
  protected $partsPath;

  /**
   * An asset dumper.
   *
   * @var \Drupal\Core\Asset\AssetDumper
   */
  protected $dumper;

  /**
   * Constructs the State object.
   *
   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory
   *   The key value store to use.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   A config factory for retrieving required config objects.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   * @param \Drupal\Core\Asset\AssetDumperInterface $asset_dumper
   *   The dumper for optimized CSS assets.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   The cache backend.
   * @param \Drupal\Core\Lock\LockBackendInterface $lock
   *   The lock backend.
   */
  public function __construct(KeyValueFactoryInterface $key_value_factory, ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, AssetDumperInterface $asset_dumper, CacheBackendInterface $cache, LockBackendInterface $lock) {
    parent::__construct($key_value_factory, $cache, $lock);
    $this->keyValueStore = $key_value_factory
      ->get('advagg_files');
    $this->config = $config_factory
      ->get('advagg.settings');
    $this->moduleHandler = $module_handler;
    $this->dumper = $asset_dumper;
    $this->partsPath = $this->dumper
      ->preparePath('css') . 'parts/';
    file_prepare_directory($this->partsPath, FILE_CREATE_DIRECTORY);
  }

  /**
   * Given a filename calculate various hashes and gather meta data.
   *
   * @param string $file
   *   A filename/path.
   * @param array $cached
   *   An array of previous values from the cache.
   * @param string $file_contents
   *   Contents of the given file.
   *
   * @return array
   *   $data which contains
   *
   * @code
   *   'filesize' => filesize($file),
   *   'mtime' => @filemtime($file),
   *   'filename_hash' => Crypt::hashBase64($file),
   *   'content_hash' => Crypt::hashBase64($file_contents),
   *   'linecount' => $linecount,
   *   'data' => $file,
   *   'fileext' => $ext,
   *   ...
   * @endcode
   */
  public function scanFile($file, array $cached = [], $file_contents = '') {

    // Clear PHP's internal file status cache.
    clearstatcache(TRUE, $file);
    if (empty($file_contents)) {
      $file_contents = (string) @file_get_contents($file);
    }
    $content_hash = Crypt::hashBase64($file_contents);
    if (!empty($cached) && $content_hash != $cached['content_hash']) {
      $changes = $cached['changes'] + 1;
    }
    else {
      $changes = 0;
    }
    $ext = pathinfo($file, PATHINFO_EXTENSION);
    if ($ext !== 'css' && $ext !== 'js') {
      if ($ext === 'less') {
        $ext = 'css';
      }
    }
    if ($ext === 'css') {

      // Get the number of selectors.
      // http://stackoverflow.com/a/12567381/125684
      $linecount = preg_match_all('/\\{.+?\\}|,/s', $file_contents);
    }
    else {

      // Get the number of lines.
      $linecount = substr_count($file_contents, "\n");
    }

    // Build meta data array.
    $data = [
      'filesize' => (int) @filesize($file),
      'mtime' => @filemtime($file),
      'filename_hash' => Crypt::hashBase64($file),
      'content_hash' => $content_hash,
      'linecount' => $linecount,
      'data' => $file,
      'fileext' => $ext,
      'updated' => REQUEST_TIME,
      'contents' => $file_contents,
      'changes' => $changes,
    ];
    if ($ext === 'css' && $linecount > $this->config
      ->get('css.ie.selector_limit')) {
      $this
        ->splitCssFile($data);
    }

    // Run hook so other modules can modify the data.
    // Call hook_advagg_scan_file_alter().
    $this->moduleHandler
      ->alter('advagg_scan_file', $file, $data, $cached);
    unset($data['contents']);
    $this
      ->set($file, $data);
    return $data;
  }

  /**
   * {@inheritdoc}
   */
  public function getMultiple(array $keys, $refresh_data = NULL) {
    $values = [];
    $load = [];
    $cache_level = $this->config
      ->get('cache_level');
    $cache_time = advagg_get_cache_time($cache_level);
    foreach ($keys as $key) {

      // Check if we have a value in the cache.
      $value = $this
        ->get($key);
      if ($value) {
        $values[$key] = $value;
      }
      else {
        $load[] = $key;
      }
    }
    if ($load) {
      $loaded_values = $this->keyValueStore
        ->getMultiple($load);
      foreach ($load as $key) {

        // If we find a value, add it to the temporary cache.
        if (isset($loaded_values[$key])) {
          if ($refresh_data === FALSE) {
            $values[$key] = $loaded_values[$key];
            $this
              ->set($key, $loaded_values[$key]);
            continue;
          }
          $file_contents = (string) @file_get_contents($key);
          if (!$refresh_data && $cache_level != -1 && !empty($loaded_values[$key]['updated'])) {

            // If data last updated too long ago check for changes.
            // Ensure the file exists.
            if (!file_exists($key)) {
              $this
                ->delete($key);
              $values[$key] = NULL;
              continue;
            }

            // If cache is Normal, check file for changes.
            if ($cache_level == 1 || REQUEST_TIME - $loaded_values[$key]['updated'] < $cache_time) {
              $content_hash = Crypt::hashBase64($file_contents);
              if ($content_hash == $loaded_values[$key]['content_hash']) {
                $values[$key] = $loaded_values[$key];
                $this
                  ->set($key, $loaded_values[$key]);
                continue;
              }
            }
          }

          // If file exists but is changed rescan.
          $values[$key] = $this
            ->scanFile($key, $loaded_values[$key], $file_contents);
          continue;
        }
        if (file_exists($key)) {

          // File has never been scanned, scan it.
          $values[$key] = $this
            ->scanFile($key);
        }
      }
    }
    return $values;
  }

  /**
   * {@inheritdoc}
   */
  public function get($key, $default = NULL) {

    // https://api.drupal.org/api/drupal/core!lib!Drupal!Core!State!State.php/function/State::get/8.3.x
    // Passthrough for Drupal 8.3+.
    if (version_compare(\Drupal::VERSION, '8.3.0') >= 0) {
      return parent::get($key, $default);
    }

    // https://api.drupal.org/api/drupal/core!lib!Drupal!Core!State!State.php/function/State::get/8.2.x
    // Use State::getMultiple vs Files::getMultiple for older Drupal 8 versions.
    $values = parent::getMultiple([
      $key,
    ]);
    return isset($values[$key]) ? $values[$key] : $default;
  }

  /**
   * Split up a CSS string by @media queries.
   *
   * @param string $css
   *   String of CSS.
   *
   * @return array
   *   array of css with only media queries.
   *
   * @see http://stackoverflow.com/questions/14145620/regular-expression-for-media-queries-in-css
   */
  private function parseMediaBlocks($css) {
    $media_blocks = [];
    $start = 0;
    $last_start = 0;

    // Using the string as an array throughout this function.
    // http://php.net/types.string#language.types.string.substr
    while (($start = strpos($css, "@media", $start)) !== FALSE) {

      // Stack to manage brackets.
      $s = [];

      // Get the first opening bracket.
      $i = strpos($css, "{", $start);

      // If $i is false, then there is probably a css syntax error.
      if ($i === FALSE) {
        continue;
      }

      // Push bracket onto stack.
      array_push($s, $css[$i]);

      // Move past first bracket.
      ++$i;

      // Find the closing bracket for the @media statement. But ensure we don't
      // overflow if there's an error.
      while (!empty($s) && isset($css[$i])) {

        // If the character is an opening bracket, push it onto the stack,
        // otherwise pop the stack.
        if ($css[$i] === "{") {
          array_push($s, "{");
        }
        elseif ($css[$i] === "}") {
          array_pop($s);
        }
        ++$i;
      }

      // Get CSS before @media and store it.
      if ($last_start != $start) {
        $insert = trim(substr($css, $last_start, $start - $last_start));
        if (!empty($insert)) {
          $media_blocks[] = $insert;
        }
      }

      // Cut @media block out of the css and store.
      $media_blocks[] = trim(substr($css, $start, $i - $start));

      // Set the new $start to the end of the block.
      $start = $i;
      $last_start = $start;
    }

    // Add in any remaining css rules after the last @media statement.
    if (strlen($css) > $last_start) {
      $insert = trim(substr($css, $last_start));
      if (!empty($insert)) {
        $media_blocks[] = $insert;
      }
    }
    return $media_blocks;
  }

  /**
   * Given a file info array it will split the file up.
   *
   * @param array $file_info
   *   File info array.
   *
   * @return array
   *   Array with file and split data.
   */
  private function splitCssFile(array &$file_info) {

    // Get the CSS file and break up by media queries.
    if (!isset($file_info['contents'])) {
      $file_info['contents'] = file_get_contents($file_info['data']);
    }
    $media_blocks = $this
      ->parseMediaBlocks($file_info['contents']);

    // Get 98% of the css.ie.selector_limit; usually 4013.
    $selector_split_value = (int) max(floor($this->config
      ->get('css.ie.selector_limit') * 0.98), 100);
    $part_selector_count = 0;
    $major_chunks = [];
    $counter = 0;

    // Group media queries together.
    foreach ($media_blocks as $media_block) {

      // Get the number of selectors.
      // http://stackoverflow.com/a/12567381/125684
      $selector_count = preg_match_all('/\\{.+?\\}|,/s', $media_block);
      $part_selector_count += $selector_count;
      if ($part_selector_count > $selector_split_value) {
        if (isset($major_chunks[$counter])) {
          ++$counter;
          $major_chunks[$counter] = $media_block;
        }
        else {
          $major_chunks[$counter] = $media_block;
        }
        ++$counter;
        $part_selector_count = 0;
      }
      else {
        if (isset($major_chunks[$counter])) {
          $major_chunks[$counter] .= "\n" . $media_block;
        }
        else {
          $major_chunks[$counter] = $media_block;
        }
      }
    }
    $file_info['parts'] = [];
    $overall_split = 0;
    $split_at = $selector_split_value;
    $chunk_split_value = (int) $this->config
      ->get('css.ie.selector_limit') - $selector_split_value - 1;
    foreach ($major_chunks as $chunks) {

      // Get the number of selectors.
      $selector_count = preg_match_all('/\\{.+?\\}|,/s', $chunks);

      // Pass through if selector count is low.
      if ($selector_count < $selector_split_value) {
        $overall_split += $selector_count;
        $subfile = $this
          ->createSubfile($chunks, $overall_split, $file_info);
        if (!$subfile) {

          // Somthing broke; do not create a subfile.
          \Drupal::logger('advagg')
            ->notice('Spliting up a CSS file failed. File info: <code>@info</code>', [
            '@info' => var_export($file_info, TRUE),
          ]);
          return [];
        }
        $file_info['parts'][] = [
          'path' => $subfile,
          'selectors' => $selector_count,
        ];
        continue;
      }
      $media_query = '';
      if (strpos($chunks, '@media') !== FALSE) {
        $media_query_pos = strpos($chunks, '{');
        $media_query = substr($chunks, 0, $media_query_pos);
        $chunks = substr($chunks, $media_query_pos + 1);
      }

      // Split CSS into selector chunks.
      $split = preg_split('/(\\{.+?\\}|,)/si', $chunks, -1, PREG_SPLIT_DELIM_CAPTURE);

      // Setup and handle media queries.
      $new_css_chunk = [
        0 => '',
      ];
      $selector_chunk_counter = 0;
      $counter = 0;
      if (!empty($media_query)) {
        $new_css_chunk[0] = $media_query . '{';
        $new_css_chunk[1] = '';
        ++$selector_chunk_counter;
        ++$counter;
      }

      // Have the key value be the running selector count and put split array
      // semi back together.
      foreach ($split as $value) {
        $new_css_chunk[$counter] .= $value;
        if (strpos($value, '}') === FALSE) {
          ++$selector_chunk_counter;
        }
        else {
          if ($counter + 1 < $selector_chunk_counter) {
            $selector_chunk_counter += ($counter - $selector_chunk_counter + 1) / 2;
          }
          $counter = $selector_chunk_counter;
          if (!isset($new_css_chunk[$counter])) {
            $new_css_chunk[$counter] = '';
          }
        }
      }

      // Group selectors.
      while (!empty($new_css_chunk)) {

        // Find where to split the array.
        $string_to_write = '';
        while (array_key_exists($split_at, $new_css_chunk) === FALSE) {
          --$split_at;
        }

        // Combine parts of the css so that it can be saved to disk.
        foreach ($new_css_chunk as $key => $value) {
          if ($key !== $split_at) {

            // Move this css row to the $string_to_write variable.
            $string_to_write .= $value;
            unset($new_css_chunk[$key]);
          }
          else {

            // Get the number of selectors in this chunk.
            $chunk_selector_count = preg_match_all('/\\{.+?\\}|,/s', $new_css_chunk[$key]);
            if ($chunk_selector_count < $chunk_split_value) {

              // The number of selectors at this point is below the threshold;
              // move this chunk to the write var and break out of the loop.
              $string_to_write .= $value;
              unset($new_css_chunk[$key]);
              $overall_split = $split_at;
              $split_at += $selector_split_value;
            }
            else {

              // The number of selectors with this chunk included is over the
              // threshold; do not move it. Change split position so the next
              // iteration of the while loop ends at the correct spot. Because
              // we skip unset here, this chunk will start the next part file.
              $overall_split = $split_at;
              $split_at += $selector_split_value - $chunk_selector_count;
            }
            break;
          }
        }

        // Handle media queries.
        if (!empty($media_query)) {

          // See if brackets need a new line.
          if (strpos($string_to_write, "\n") === 0) {
            $open_bracket = '{';
          }
          else {
            $open_bracket = "{\n";
          }
          if (strrpos($string_to_write, "\n") === strlen($string_to_write)) {
            $close_bracket = '}';
          }
          else {
            $close_bracket = "\n}";
          }

          // Fix syntax around media queries.
          if ($first) {
            $string_to_write .= $close_bracket;
          }
          elseif (empty($new_css_chunk)) {
            $string_to_write = $media_query . $open_bracket . $string_to_write;
          }
          else {
            $string_to_write = $media_query . $open_bracket . $string_to_write . $close_bracket;
          }
        }

        // Write the data.
        $subfile = $this
          ->createSubfile($string_to_write, $overall_split, $file_info);
        if (!$subfile) {

          // Somthing broke; did not create a subfile.
          \Drupal::logger('advagg')
            ->notice('Spliting up a CSS file failed. File info: <code>@info</code>', [
            '@info' => var_export($file_info, TRUE),
          ]);
          return [];
        }
        $sub_selector_count = preg_match_all('/\\{.+?\\}|,/s', $string_to_write, $matches);
        $file_info['parts'][] = [
          'path' => $subfile,
          'selectors' => $sub_selector_count,
        ];
      }
    }
  }

  /**
   * Write CSS parts to disk; used when CSS selectors in one file is > 4096.
   *
   * @param string $css
   *   CSS data to write to disk.
   * @param int $overall_split
   *   Running count of what selector we are from the original file.
   * @param array $file_info
   *   File info array.
   *
   * @return string
   *   Saved path; FALSE on failure.
   */
  private function createSubfile($css, $overall_split, array &$file_info) {

    // Get the path from $file_info['data'].
    $file = advagg_get_relative_path($file_info['data']);
    if (!file_exists($file) || is_dir($file)) {
      return FALSE;
    }

    // Write the current chunk of the CSS into a file.
    $path = $this->partsPath . $file . $overall_split . '.css';
    $directory = dirname($path);
    file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
    file_unmanaged_save_data($css, $path, FILE_EXISTS_REPLACE);
    if (!file_exists($path)) {
      return FALSE;
    }
    return $path;
  }

}

Classes

Namesort descending Description
Files Provides AdvAgg with a file status state system using a key value store.