Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 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       * Add a file to be minified.
 104       *
 105       * @param string|string[] $data
 106       *
 107       * @return static
 108       *
 109       * @throws IOException
 110       */
 111      public function addFile($data /* $data = null, ... */)
 112      {
 113          // bogus "usage" of parameter $data: scrutinizer warns this variable is
 114          // not used (we're using func_get_args instead to support overloading),
 115          // but it still needs to be defined because it makes no sense to have
 116          // this function without argument :)
 117          $args = array($data) + func_get_args();
 118  
 119          // this method can be overloaded
 120          foreach ($args as $path) {
 121              if (is_array($path)) {
 122                  call_user_func_array(array($this, 'addFile'), $path);
 123                  continue;
 124              }
 125  
 126              // redefine var
 127              $path = (string) $path;
 128  
 129              // check if we can read the file
 130              if (!$this->canImportFile($path)) {
 131                  throw new IOException('The file "'.$path.'" could not be opened for reading. Check if PHP has enough permissions.');
 132              }
 133  
 134              $this->add($path);
 135          }
 136  
 137          return $this;
 138      }
 139  
 140      /**
 141       * Minify the data & (optionally) saves it to a file.
 142       *
 143       * @param string[optional] $path Path to write the data to
 144       *
 145       * @return string The minified data
 146       */
 147      public function minify($path = null)
 148      {
 149          $content = $this->execute($path);
 150  
 151          // save to path
 152          if ($path !== null) {
 153              $this->save($content, $path);
 154          }
 155  
 156          return $content;
 157      }
 158  
 159      /**
 160       * Minify & gzip the data & (optionally) saves it to a file.
 161       *
 162       * @param string[optional] $path  Path to write the data to
 163       * @param int[optional]    $level Compression level, from 0 to 9
 164       *
 165       * @return string The minified & gzipped data
 166       */
 167      public function gzip($path = null, $level = 9)
 168      {
 169          $content = $this->execute($path);
 170          $content = gzencode($content, $level, FORCE_GZIP);
 171  
 172          // save to path
 173          if ($path !== null) {
 174              $this->save($content, $path);
 175          }
 176  
 177          return $content;
 178      }
 179  
 180      /**
 181       * Minify the data & write it to a CacheItemInterface object.
 182       *
 183       * @param CacheItemInterface $item Cache item to write the data to
 184       *
 185       * @return CacheItemInterface Cache item with the minifier data
 186       */
 187      public function cache(CacheItemInterface $item)
 188      {
 189          $content = $this->execute();
 190          $item->set($content);
 191  
 192          return $item;
 193      }
 194  
 195      /**
 196       * Minify the data.
 197       *
 198       * @param string[optional] $path Path to write the data to
 199       *
 200       * @return string The minified data
 201       */
 202      abstract public function execute($path = null);
 203  
 204      /**
 205       * Load data.
 206       *
 207       * @param string $data Either a path to a file or the content itself
 208       *
 209       * @return string
 210       */
 211      protected function load($data)
 212      {
 213          // check if the data is a file
 214          if ($this->canImportFile($data)) {
 215              $data = file_get_contents($data);
 216  
 217              // strip BOM, if any
 218              if (substr($data, 0, 3) == "\xef\xbb\xbf") {
 219                  $data = substr($data, 3);
 220              }
 221          }
 222  
 223          return $data;
 224      }
 225  
 226      /**
 227       * Save to file.
 228       *
 229       * @param string $content The minified data
 230       * @param string $path    The path to save the minified data to
 231       *
 232       * @throws IOException
 233       */
 234      protected function save($content, $path)
 235      {
 236          $handler = $this->openFileForWriting($path);
 237  
 238          $this->writeToFile($handler, $content);
 239  
 240          @fclose($handler);
 241      }
 242  
 243      /**
 244       * Register a pattern to execute against the source content.
 245       *
 246       * If $replacement is a string, it must be plain text. Placeholders like $1 or \2 don't work.
 247       * If you need that functionality, use a callback instead.
 248       *
 249       * @param string          $pattern     PCRE pattern
 250       * @param string|callable $replacement Replacement value for matched pattern
 251       */
 252      protected function registerPattern($pattern, $replacement = '')
 253      {
 254          // study the pattern, we'll execute it more than once
 255          $pattern .= 'S';
 256  
 257          $this->patterns[] = array($pattern, $replacement);
 258      }
 259  
 260      /**
 261       * We can't "just" run some regular expressions against JavaScript: it's a
 262       * complex language. E.g. having an occurrence of // xyz would be a comment,
 263       * unless it's used within a string. Of you could have something that looks
 264       * like a 'string', but inside a comment.
 265       * The only way to accurately replace these pieces is to traverse the JS one
 266       * character at a time and try to find whatever starts first.
 267       *
 268       * @param string $content The content to replace patterns in
 269       *
 270       * @return string The (manipulated) content
 271       */
 272      protected function replace($content)
 273      {
 274          $contentLength = strlen($content);
 275          $output = '';
 276          $processedOffset = 0;
 277          $positions = array_fill(0, count($this->patterns), -1);
 278          $matches = array();
 279  
 280          while ($processedOffset < $contentLength) {
 281              // find first match for all patterns
 282              foreach ($this->patterns as $i => $pattern) {
 283                  list($pattern, $replacement) = $pattern;
 284  
 285                  // we can safely ignore patterns for positions we've unset earlier,
 286                  // because we know these won't show up anymore
 287                  if (array_key_exists($i, $positions) == false) {
 288                      continue;
 289                  }
 290  
 291                  // no need to re-run matches that are still in the part of the
 292                  // content that hasn't been processed
 293                  if ($positions[$i] >= $processedOffset) {
 294                      continue;
 295                  }
 296  
 297                  $match = null;
 298                  if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $processedOffset)) {
 299                      $matches[$i] = $match;
 300  
 301                      // we'll store the match position as well; that way, we
 302                      // don't have to redo all preg_matches after changing only
 303                      // the first (we'll still know where those others are)
 304                      $positions[$i] = $match[0][1];
 305                  } else {
 306                      // if the pattern couldn't be matched, there's no point in
 307                      // executing it again in later runs on this same content;
 308                      // ignore this one until we reach end of content
 309                      unset($matches[$i], $positions[$i]);
 310                  }
 311              }
 312  
 313              // no more matches to find: everything's been processed, break out
 314              if (!$matches) {
 315                  // output the remaining content
 316                  $output .= substr($content, $processedOffset);
 317                  break;
 318              }
 319  
 320              // see which of the patterns actually found the first thing (we'll
 321              // only want to execute that one, since we're unsure if what the
 322              // other found was not inside what the first found)
 323              $matchOffset = min($positions);
 324              $firstPattern = array_search($matchOffset, $positions);
 325              $match = $matches[$firstPattern];
 326  
 327              // execute the pattern that matches earliest in the content string
 328              list(, $replacement) = $this->patterns[$firstPattern];
 329  
 330              // add the part of the input between $processedOffset and the first match;
 331              // that content wasn't matched by anything
 332              $output .= substr($content, $processedOffset, $matchOffset - $processedOffset);
 333              // add the replacement for the match
 334              $output .= $this->executeReplacement($replacement, $match);
 335              // advance $processedOffset past the match
 336              $processedOffset = $matchOffset + strlen($match[0][0]);
 337          }
 338  
 339          return $output;
 340      }
 341  
 342      /**
 343       * If $replacement is a callback, execute it, passing in the match data.
 344       * If it's a string, just pass it through.
 345       *
 346       * @param string|callable $replacement Replacement value
 347       * @param array           $match       Match data, in PREG_OFFSET_CAPTURE form
 348       *
 349       * @return string
 350       */
 351      protected function executeReplacement($replacement, $match)
 352      {
 353          if (!is_callable($replacement)) {
 354              return $replacement;
 355          }
 356          // convert $match from the PREG_OFFSET_CAPTURE form to the form the callback expects
 357          foreach ($match as &$matchItem) {
 358              $matchItem = $matchItem[0];
 359          }
 360          return $replacement($match);
 361      }
 362  
 363      /**
 364       * Strings are a pattern we need to match, in order to ignore potential
 365       * code-like content inside them, but we just want all of the string
 366       * content to remain untouched.
 367       *
 368       * This method will replace all string content with simple STRING#
 369       * placeholder text, so we've rid all strings from characters that may be
 370       * misinterpreted. Original string content will be saved in $this->extracted
 371       * and after doing all other minifying, we can restore the original content
 372       * via restoreStrings().
 373       *
 374       * @param string[optional] $chars
 375       * @param string[optional] $placeholderPrefix
 376       */
 377      protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
 378      {
 379          // PHP only supports $this inside anonymous functions since 5.4
 380          $minifier = $this;
 381          $callback = function ($match) use ($minifier, $placeholderPrefix) {
 382              // check the second index here, because the first always contains a quote
 383              if ($match[2] === '') {
 384                  /*
 385                   * Empty strings need no placeholder; they can't be confused for
 386                   * anything else anyway.
 387                   * But we still needed to match them, for the extraction routine
 388                   * to skip over this particular string.
 389                   */
 390                  return $match[0];
 391              }
 392  
 393              $count = count($minifier->extracted);
 394              $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
 395              $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
 396  
 397              return $placeholder;
 398          };
 399  
 400          /*
 401           * The \\ messiness explained:
 402           * * Don't count ' or " as end-of-string if it's escaped (has backslash
 403           * in front of it)
 404           * * Unless... that backslash itself is escaped (another leading slash),
 405           * in which case it's no longer escaping the ' or "
 406           * * So there can be either no backslash, or an even number
 407           * * multiply all of that times 4, to account for the escaping that has
 408           * to be done to pass the backslash into the PHP string without it being
 409           * considered as escape-char (times 2) and to get it in the regex,
 410           * escaped (times 2)
 411           */
 412          $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
 413      }
 414  
 415      /**
 416       * This method will restore all extracted data (strings, regexes) that were
 417       * replaced with placeholder text in extract*(). The original content was
 418       * saved in $this->extracted.
 419       *
 420       * @param string $content
 421       *
 422       * @return string
 423       */
 424      protected function restoreExtractedData($content)
 425      {
 426          if (!$this->extracted) {
 427              // nothing was extracted, nothing to restore
 428              return $content;
 429          }
 430  
 431          $content = strtr($content, $this->extracted);
 432  
 433          $this->extracted = array();
 434  
 435          return $content;
 436      }
 437  
 438      /**
 439       * Check if the path is a regular file and can be read.
 440       *
 441       * @param string $path
 442       *
 443       * @return bool
 444       */
 445      protected function canImportFile($path)
 446      {
 447          $parsed = parse_url($path);
 448          if (
 449              // file is elsewhere
 450              isset($parsed['host']) ||
 451              // file responds to queries (may change, or need to bypass cache)
 452              isset($parsed['query'])
 453          ) {
 454              return false;
 455          }
 456  
 457          return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
 458      }
 459  
 460      /**
 461       * Attempts to open file specified by $path for writing.
 462       *
 463       * @param string $path The path to the file
 464       *
 465       * @return resource Specifier for the target file
 466       *
 467       * @throws IOException
 468       */
 469      protected function openFileForWriting($path)
 470      {
 471          if ($path === '' || ($handler = @fopen($path, 'w')) === false) {
 472              throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
 473          }
 474  
 475          return $handler;
 476      }
 477  
 478      /**
 479       * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
 480       *
 481       * @param resource $handler The resource to write to
 482       * @param string   $content The content to write
 483       * @param string   $path    The path to the file (for exception printing only)
 484       *
 485       * @throws IOException
 486       */
 487      protected function writeToFile($handler, $content, $path = '')
 488      {
 489          if (
 490              !is_resource($handler) ||
 491              ($result = @fwrite($handler, $content)) === false ||
 492              ($result < strlen($content))
 493          ) {
 494              throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
 495          }
 496      }
 497  
 498      protected static function str_replace_first($search, $replace, $subject) {
 499          $pos = strpos($subject, $search);
 500          if ($pos !== false) {
 501              return substr_replace($subject, $replace, $pos, strlen($search));
 502          }
 503          return $subject;
 504      }
 505  }