Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 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.
<?php
/**
 * Abstract minifier class
 *
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
 *
 * @author Matthias Mullie <minify@mullie.eu>
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
 * @license MIT License
 */
namespace MatthiasMullie\Minify;

use MatthiasMullie\Minify\Exceptions\IOException;
use Psr\Cache\CacheItemInterface;

/**
 * Abstract minifier class.
 *
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
 *
 * @package Minify
 * @author Matthias Mullie <minify@mullie.eu>
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
 * @license MIT License
 */
abstract class Minify
{
    /**
     * The data to be minified.
     *
     * @var string[]
     */
    protected $data = array();

    /**
     * Array of patterns to match.
     *
     * @var string[]
     */
    protected $patterns = array();

    /**
     * This array will hold content of strings and regular expressions that have
     * been extracted from the JS source code, so we can reliably match "code",
     * without having to worry about potential "code-like" characters inside.
     *
     * @var string[]
     */
    public $extracted = array();

    /**
     * Init the minify class - optionally, code may be passed along already.
     */
    public function __construct(/* $data = null, ... */)
    {
        // it's possible to add the source through the constructor as well ;)
        if (func_num_args()) {
            call_user_func_array(array($this, 'add'), func_get_args());
        }
    }

    /**
     * Add a file or straight-up code to be minified.
     *
     * @param string|string[] $data
     *
     * @return static
     */
    public function add($data /* $data = null, ... */)
    {
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
        // not used (we're using func_get_args instead to support overloading),
        // but it still needs to be defined because it makes no sense to have
        // this function without argument :)
        $args = array($data) + func_get_args();

        // this method can be overloaded
        foreach ($args as $data) {
            if (is_array($data)) {
                call_user_func_array(array($this, 'add'), $data);
                continue;
            }

            // redefine var
            $data = (string) $data;

            // load data
            $value = $this->load($data);
            $key = ($data != $value) ? $data : count($this->data);

            // replace CR linefeeds etc.
            // @see https://github.com/matthiasmullie/minify/pull/139
            $value = str_replace(array("\r\n", "\r"), "\n", $value);

            // store data
            $this->data[$key] = $value;
        }

        return $this;
    }

    /**
> * Add a file to be minified. * Minify the data & (optionally) saves it to a file. > * * > * @param string|string[] $data * @param string[optional] $path Path to write the data to > * * > * @return static * @return string The minified data > * */ > * @throws IOException public function minify($path = null) > */ { > public function addFile($data /* $data = null, ... */) $content = $this->execute($path); > { > // bogus "usage" of parameter $data: scrutinizer warns this variable is // save to path > // not used (we're using func_get_args instead to support overloading), if ($path !== null) { > // but it still needs to be defined because it makes no sense to have $this->save($content, $path); > // this function without argument :) } > $args = array($data) + func_get_args(); > return $content; > // this method can be overloaded } > foreach ($args as $path) { > if (is_array($path)) { /** > call_user_func_array(array($this, 'addFile'), $path); * Minify & gzip the data & (optionally) saves it to a file. > continue; * > } * @param string[optional] $path Path to write the data to > * @param int[optional] $level Compression level, from 0 to 9 > // redefine var * > $path = (string) $path; * @return string The minified & gzipped data > */ > // check if we can read the file public function gzip($path = null, $level = 9) > if (!$this->canImportFile($path)) { { > throw new IOException('The file "'.$path.'" could not be opened for reading. Check if PHP has enough permissions.'); $content = $this->execute($path); > } $content = gzencode($content, $level, FORCE_GZIP); > > $this->add($path); // save to path > } if ($path !== null) { > $this->save($content, $path); > return $this; } > } > return $content; > /**
} /** * Minify the data & write it to a CacheItemInterface object. * * @param CacheItemInterface $item Cache item to write the data to * * @return CacheItemInterface Cache item with the minifier data */ public function cache(CacheItemInterface $item) { $content = $this->execute(); $item->set($content); return $item; } /** * Minify the data. * * @param string[optional] $path Path to write the data to * * @return string The minified data */ abstract public function execute($path = null); /** * Load data. * * @param string $data Either a path to a file or the content itself * * @return string */ protected function load($data) { // check if the data is a file if ($this->canImportFile($data)) { $data = file_get_contents($data); // strip BOM, if any if (substr($data, 0, 3) == "\xef\xbb\xbf") { $data = substr($data, 3); } } return $data; } /** * Save to file. * * @param string $content The minified data * @param string $path The path to save the minified data to * * @throws IOException */ protected function save($content, $path) { $handler = $this->openFileForWriting($path); $this->writeToFile($handler, $content); @fclose($handler); } /** * Register a pattern to execute against the source content. * * @param string $pattern PCRE pattern * @param string|callable $replacement Replacement value for matched pattern */ protected function registerPattern($pattern, $replacement = '') { // study the pattern, we'll execute it more than once $pattern .= 'S'; $this->patterns[] = array($pattern, $replacement); } /** * We can't "just" run some regular expressions against JavaScript: it's a * complex language. E.g. having an occurrence of // xyz would be a comment, * unless it's used within a string. Of you could have something that looks * like a 'string', but inside a comment. * The only way to accurately replace these pieces is to traverse the JS one * character at a time and try to find whatever starts first. * * @param string $content The content to replace patterns in * * @return string The (manipulated) content */ protected function replace($content) { $processed = ''; $positions = array_fill(0, count($this->patterns), -1); $matches = array(); while ($content) { // find first match for all patterns foreach ($this->patterns as $i => $pattern) { list($pattern, $replacement) = $pattern; // we can safely ignore patterns for positions we've unset earlier, // because we know these won't show up anymore if (array_key_exists($i, $positions) == false) { continue; } // no need to re-run matches that are still in the part of the // content that hasn't been processed if ($positions[$i] >= 0) { continue; } $match = null; if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) { $matches[$i] = $match; // we'll store the match position as well; that way, we // don't have to redo all preg_matches after changing only // the first (we'll still know where those others are) $positions[$i] = $match[0][1]; } else { // if the pattern couldn't be matched, there's no point in // executing it again in later runs on this same content; // ignore this one until we reach end of content unset($matches[$i], $positions[$i]); } } // no more matches to find: everything's been processed, break out if (!$matches) { $processed .= $content; break; } // see which of the patterns actually found the first thing (we'll // only want to execute that one, since we're unsure if what the // other found was not inside what the first found) $discardLength = min($positions); $firstPattern = array_search($discardLength, $positions); $match = $matches[$firstPattern][0][0]; // execute the pattern that matches earliest in the content string list($pattern, $replacement) = $this->patterns[$firstPattern]; $replacement = $this->replacePattern($pattern, $replacement, $content); // figure out which part of the string was unmatched; that's the // part we'll execute the patterns on again next $content = (string) substr($content, $discardLength); $unmatched = (string) substr($content, strpos($content, $match) + strlen($match)); // move the replaced part to $processed and prepare $content to // again match batch of patterns against $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched)); $content = $unmatched; // first match has been replaced & that content is to be left alone, // the next matches will start after this replacement, so we should // fix their offsets foreach ($positions as $i => $position) { $positions[$i] -= $discardLength + strlen($match); } } return $processed; } /** * This is where a pattern is matched against $content and the matches * are replaced by their respective value. * This function will be called plenty of times, where $content will always * move up 1 character. * * @param string $pattern Pattern to match * @param string|callable $replacement Replacement value * @param string $content Content to match pattern against * * @return string */ protected function replacePattern($pattern, $replacement, $content) { if (is_callable($replacement)) { return preg_replace_callback($pattern, $replacement, $content, 1, $count); } else { return preg_replace($pattern, $replacement, $content, 1, $count); } } /** * Strings are a pattern we need to match, in order to ignore potential * code-like content inside them, but we just want all of the string * content to remain untouched. * * This method will replace all string content with simple STRING# * placeholder text, so we've rid all strings from characters that may be * misinterpreted. Original string content will be saved in $this->extracted * and after doing all other minifying, we can restore the original content * via restoreStrings(). * * @param string[optional] $chars * @param string[optional] $placeholderPrefix */ protected function extractStrings($chars = '\'"', $placeholderPrefix = '') { // PHP only supports $this inside anonymous functions since 5.4 $minifier = $this; $callback = function ($match) use ($minifier, $placeholderPrefix) { // check the second index here, because the first always contains a quote if ($match[2] === '') { /* * Empty strings need no placeholder; they can't be confused for * anything else anyway. * But we still needed to match them, for the extraction routine * to skip over this particular string. */ return $match[0]; } $count = count($minifier->extracted); $placeholder = $match[1].$placeholderPrefix.$count.$match[1]; $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1]; return $placeholder; }; /* * The \\ messiness explained: * * Don't count ' or " as end-of-string if it's escaped (has backslash * in front of it) * * Unless... that backslash itself is escaped (another leading slash), * in which case it's no longer escaping the ' or " * * So there can be either no backslash, or an even number * * multiply all of that times 4, to account for the escaping that has * to be done to pass the backslash into the PHP string without it being * considered as escape-char (times 2) and to get it in the regex, * escaped (times 2) */ $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback); } /** * This method will restore all extracted data (strings, regexes) that were * replaced with placeholder text in extract*(). The original content was * saved in $this->extracted. * * @param string $content * * @return string */ protected function restoreExtractedData($content) { if (!$this->extracted) { // nothing was extracted, nothing to restore return $content; } $content = strtr($content, $this->extracted); $this->extracted = array(); return $content; } /** * Check if the path is a regular file and can be read. * * @param string $path * * @return bool */ protected function canImportFile($path) { $parsed = parse_url($path); if ( // file is elsewhere isset($parsed['host']) || // file responds to queries (may change, or need to bypass cache) isset($parsed['query']) ) { return false; } return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path); } /** * Attempts to open file specified by $path for writing. * * @param string $path The path to the file * * @return resource Specifier for the target file * * @throws IOException */ protected function openFileForWriting($path) {
< if (($handler = @fopen($path, 'w')) === false) {
> if ($path === '' || ($handler = @fopen($path, 'w')) === false) {
throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.'); } return $handler; } /** * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions. * * @param resource $handler The resource to write to * @param string $content The content to write * @param string $path The path to the file (for exception printing only) * * @throws IOException */ protected function writeToFile($handler, $content, $path = '') {
< if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
> if ( > !is_resource($handler) || > ($result = @fwrite($handler, $content)) === false || > ($result < strlen($content)) > ) {
throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.'); } } }