Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   1  <?php
   2  /**
   3   * Abstract minifier class
   4   *
   5   * Please report bugs on https://github.com/matthiasmullie/minify/issues
   6   *
   7   * @author Matthias Mullie <minify@mullie.eu>
   8   * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
   9   * @license MIT License
  10   */
  11  namespace MatthiasMullie\Minify;
  12  
  13  use MatthiasMullie\Minify\Exceptions\IOException;
  14  use Psr\Cache\CacheItemInterface;
  15  
  16  /**
  17   * Abstract minifier class.
  18   *
  19   * Please report bugs on https://github.com/matthiasmullie/minify/issues
  20   *
  21   * @package Minify
  22   * @author Matthias Mullie <minify@mullie.eu>
  23   * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
  24   * @license MIT License
  25   */
  26  abstract class Minify
  27  {
  28      /**
  29       * The data to be minified.
  30       *
  31       * @var string[]
  32       */
  33      protected $data = array();
  34  
  35      /**
  36       * Array of patterns to match.
  37       *
  38       * @var string[]
  39       */
  40      protected $patterns = array();
  41  
  42      /**
  43       * This array will hold content of strings and regular expressions that have
  44       * been extracted from the JS source code, so we can reliably match "code",
  45       * without having to worry about potential "code-like" characters inside.
  46       *
  47       * @var string[]
  48       */
  49      public $extracted = array();
  50  
  51      /**
  52       * Init the minify class - optionally, code may be passed along already.
  53       */
  54      public function __construct(/* $data = null, ... */)
  55      {
  56          // it's possible to add the source through the constructor as well ;)
  57          if (func_num_args()) {
  58              call_user_func_array(array($this, 'add'), func_get_args());
  59          }
  60      }
  61  
  62      /**
  63       * Add a file or straight-up code to be minified.
  64       *
  65       * @param string|string[] $data
  66       *
  67       * @return static
  68       */
  69      public function add($data /* $data = null, ... */)
  70      {
  71          // bogus "usage" of parameter $data: scrutinizer warns this variable is
  72          // not used (we're using func_get_args instead to support overloading),
  73          // but it still needs to be defined because it makes no sense to have
  74          // this function without argument :)
  75          $args = array($data) + func_get_args();
  76  
  77          // this method can be overloaded
  78          foreach ($args as $data) {
  79              if (is_array($data)) {
  80                  call_user_func_array(array($this, 'add'), $data);
  81                  continue;
  82              }
  83  
  84              // redefine var
  85              $data = (string) $data;
  86  
  87              // load data
  88              $value = $this->load($data);
  89              $key = ($data != $value) ? $data : count($this->data);
  90  
  91              // replace CR linefeeds etc.
  92              // @see https://github.com/matthiasmullie/minify/pull/139
  93              $value = str_replace(array("\r\n", "\r"), "\n", $value);
  94  
  95              // store data
  96              $this->data[$key] = $value;
  97          }
  98  
  99          return $this;
 100      }
 101  
 102      /**
 103       * Minify the data & (optionally) saves it to a file.
 104       *
 105       * @param string[optional] $path Path to write the data to
 106       *
 107       * @return string The minified data
 108       */
 109      public function minify($path = null)
 110      {
 111          $content = $this->execute($path);
 112  
 113          // save to path
 114          if ($path !== null) {
 115              $this->save($content, $path);
 116          }
 117  
 118          return $content;
 119      }
 120  
 121      /**
 122       * Minify & gzip the data & (optionally) saves it to a file.
 123       *
 124       * @param string[optional] $path  Path to write the data to
 125       * @param int[optional]    $level Compression level, from 0 to 9
 126       *
 127       * @return string The minified & gzipped data
 128       */
 129      public function gzip($path = null, $level = 9)
 130      {
 131          $content = $this->execute($path);
 132          $content = gzencode($content, $level, FORCE_GZIP);
 133  
 134          // save to path
 135          if ($path !== null) {
 136              $this->save($content, $path);
 137          }
 138  
 139          return $content;
 140      }
 141  
 142      /**
 143       * Minify the data & write it to a CacheItemInterface object.
 144       *
 145       * @param CacheItemInterface $item Cache item to write the data to
 146       *
 147       * @return CacheItemInterface Cache item with the minifier data
 148       */
 149      public function cache(CacheItemInterface $item)
 150      {
 151          $content = $this->execute();
 152          $item->set($content);
 153  
 154          return $item;
 155      }
 156  
 157      /**
 158       * Minify the data.
 159       *
 160       * @param string[optional] $path Path to write the data to
 161       *
 162       * @return string The minified data
 163       */
 164      abstract public function execute($path = null);
 165  
 166      /**
 167       * Load data.
 168       *
 169       * @param string $data Either a path to a file or the content itself
 170       *
 171       * @return string
 172       */
 173      protected function load($data)
 174      {
 175          // check if the data is a file
 176          if ($this->canImportFile($data)) {
 177              $data = file_get_contents($data);
 178  
 179              // strip BOM, if any
 180              if (substr($data, 0, 3) == "\xef\xbb\xbf") {
 181                  $data = substr($data, 3);
 182              }
 183          }
 184  
 185          return $data;
 186      }
 187  
 188      /**
 189       * Save to file.
 190       *
 191       * @param string $content The minified data
 192       * @param string $path    The path to save the minified data to
 193       *
 194       * @throws IOException
 195       */
 196      protected function save($content, $path)
 197      {
 198          $handler = $this->openFileForWriting($path);
 199  
 200          $this->writeToFile($handler, $content);
 201  
 202          @fclose($handler);
 203      }
 204  
 205      /**
 206       * Register a pattern to execute against the source content.
 207       *
 208       * @param string          $pattern     PCRE pattern
 209       * @param string|callable $replacement Replacement value for matched pattern
 210       */
 211      protected function registerPattern($pattern, $replacement = '')
 212      {
 213          // study the pattern, we'll execute it more than once
 214          $pattern .= 'S';
 215  
 216          $this->patterns[] = array($pattern, $replacement);
 217      }
 218  
 219      /**
 220       * We can't "just" run some regular expressions against JavaScript: it's a
 221       * complex language. E.g. having an occurrence of // xyz would be a comment,
 222       * unless it's used within a string. Of you could have something that looks
 223       * like a 'string', but inside a comment.
 224       * The only way to accurately replace these pieces is to traverse the JS one
 225       * character at a time and try to find whatever starts first.
 226       *
 227       * @param string $content The content to replace patterns in
 228       *
 229       * @return string The (manipulated) content
 230       */
 231      protected function replace($content)
 232      {
 233          $processed = '';
 234          $positions = array_fill(0, count($this->patterns), -1);
 235          $matches = array();
 236  
 237          while ($content) {
 238              // find first match for all patterns
 239              foreach ($this->patterns as $i => $pattern) {
 240                  list($pattern, $replacement) = $pattern;
 241  
 242                  // we can safely ignore patterns for positions we've unset earlier,
 243                  // because we know these won't show up anymore
 244                  if (array_key_exists($i, $positions) == false) {
 245                      continue;
 246                  }
 247  
 248                  // no need to re-run matches that are still in the part of the
 249                  // content that hasn't been processed
 250                  if ($positions[$i] >= 0) {
 251                      continue;
 252                  }
 253  
 254                  $match = null;
 255                  if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {
 256                      $matches[$i] = $match;
 257  
 258                      // we'll store the match position as well; that way, we
 259                      // don't have to redo all preg_matches after changing only
 260                      // the first (we'll still know where those others are)
 261                      $positions[$i] = $match[0][1];
 262                  } else {
 263                      // if the pattern couldn't be matched, there's no point in
 264                      // executing it again in later runs on this same content;
 265                      // ignore this one until we reach end of content
 266                      unset($matches[$i], $positions[$i]);
 267                  }
 268              }
 269  
 270              // no more matches to find: everything's been processed, break out
 271              if (!$matches) {
 272                  $processed .= $content;
 273                  break;
 274              }
 275  
 276              // see which of the patterns actually found the first thing (we'll
 277              // only want to execute that one, since we're unsure if what the
 278              // other found was not inside what the first found)
 279              $discardLength = min($positions);
 280              $firstPattern = array_search($discardLength, $positions);
 281              $match = $matches[$firstPattern][0][0];
 282  
 283              // execute the pattern that matches earliest in the content string
 284              list($pattern, $replacement) = $this->patterns[$firstPattern];
 285              $replacement = $this->replacePattern($pattern, $replacement, $content);
 286  
 287              // figure out which part of the string was unmatched; that's the
 288              // part we'll execute the patterns on again next
 289              $content = (string) substr($content, $discardLength);
 290              $unmatched = (string) substr($content, strpos($content, $match) + strlen($match));
 291  
 292              // move the replaced part to $processed and prepare $content to
 293              // again match batch of patterns against
 294              $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched));
 295              $content = $unmatched;
 296  
 297              // first match has been replaced & that content is to be left alone,
 298              // the next matches will start after this replacement, so we should
 299              // fix their offsets
 300              foreach ($positions as $i => $position) {
 301                  $positions[$i] -= $discardLength + strlen($match);
 302              }
 303          }
 304  
 305          return $processed;
 306      }
 307  
 308      /**
 309       * This is where a pattern is matched against $content and the matches
 310       * are replaced by their respective value.
 311       * This function will be called plenty of times, where $content will always
 312       * move up 1 character.
 313       *
 314       * @param string          $pattern     Pattern to match
 315       * @param string|callable $replacement Replacement value
 316       * @param string          $content     Content to match pattern against
 317       *
 318       * @return string
 319       */
 320      protected function replacePattern($pattern, $replacement, $content)
 321      {
 322          if (is_callable($replacement)) {
 323              return preg_replace_callback($pattern, $replacement, $content, 1, $count);
 324          } else {
 325              return preg_replace($pattern, $replacement, $content, 1, $count);
 326          }
 327      }
 328  
 329      /**
 330       * Strings are a pattern we need to match, in order to ignore potential
 331       * code-like content inside them, but we just want all of the string
 332       * content to remain untouched.
 333       *
 334       * This method will replace all string content with simple STRING#
 335       * placeholder text, so we've rid all strings from characters that may be
 336       * misinterpreted. Original string content will be saved in $this->extracted
 337       * and after doing all other minifying, we can restore the original content
 338       * via restoreStrings().
 339       *
 340       * @param string[optional] $chars
 341       * @param string[optional] $placeholderPrefix
 342       */
 343      protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
 344      {
 345          // PHP only supports $this inside anonymous functions since 5.4
 346          $minifier = $this;
 347          $callback = function ($match) use ($minifier, $placeholderPrefix) {
 348              // check the second index here, because the first always contains a quote
 349              if ($match[2] === '') {
 350                  /*
 351                   * Empty strings need no placeholder; they can't be confused for
 352                   * anything else anyway.
 353                   * But we still needed to match them, for the extraction routine
 354                   * to skip over this particular string.
 355                   */
 356                  return $match[0];
 357              }
 358  
 359              $count = count($minifier->extracted);
 360              $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
 361              $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
 362  
 363              return $placeholder;
 364          };
 365  
 366          /*
 367           * The \\ messiness explained:
 368           * * Don't count ' or " as end-of-string if it's escaped (has backslash
 369           * in front of it)
 370           * * Unless... that backslash itself is escaped (another leading slash),
 371           * in which case it's no longer escaping the ' or "
 372           * * So there can be either no backslash, or an even number
 373           * * multiply all of that times 4, to account for the escaping that has
 374           * to be done to pass the backslash into the PHP string without it being
 375           * considered as escape-char (times 2) and to get it in the regex,
 376           * escaped (times 2)
 377           */
 378          $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
 379      }
 380  
 381      /**
 382       * This method will restore all extracted data (strings, regexes) that were
 383       * replaced with placeholder text in extract*(). The original content was
 384       * saved in $this->extracted.
 385       *
 386       * @param string $content
 387       *
 388       * @return string
 389       */
 390      protected function restoreExtractedData($content)
 391      {
 392          if (!$this->extracted) {
 393              // nothing was extracted, nothing to restore
 394              return $content;
 395          }
 396  
 397          $content = strtr($content, $this->extracted);
 398  
 399          $this->extracted = array();
 400  
 401          return $content;
 402      }
 403  
 404      /**
 405       * Check if the path is a regular file and can be read.
 406       *
 407       * @param string $path
 408       *
 409       * @return bool
 410       */
 411      protected function canImportFile($path)
 412      {
 413          $parsed = parse_url($path);
 414          if (
 415              // file is elsewhere
 416              isset($parsed['host']) ||
 417              // file responds to queries (may change, or need to bypass cache)
 418              isset($parsed['query'])
 419          ) {
 420              return false;
 421          }
 422  
 423          return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
 424      }
 425  
 426      /**
 427       * Attempts to open file specified by $path for writing.
 428       *
 429       * @param string $path The path to the file
 430       *
 431       * @return resource Specifier for the target file
 432       *
 433       * @throws IOException
 434       */
 435      protected function openFileForWriting($path)
 436      {
 437          if (($handler = @fopen($path, 'w')) === false) {
 438              throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
 439          }
 440  
 441          return $handler;
 442      }
 443  
 444      /**
 445       * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
 446       *
 447       * @param resource $handler The resource to write to
 448       * @param string   $content The content to write
 449       * @param string   $path    The path to the file (for exception printing only)
 450       *
 451       * @throws IOException
 452       */
 453      protected function writeToFile($handler, $content, $path = '')
 454      {
 455          if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
 456              throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
 457          }
 458      }
 459  }