Scripts

CSS & JS Minifier

CSS & JavaScript minifier in PHP. CSS * Verwijdert comments * Verwijdert witruimte * Importeert @import-ed CSS bestanden * Embed kleine statische bestanden in minified bestand (base64-geencodeerd) * Verkort hexadecimale kleurcodes * Verkort nulwaardes (zoals -0px) JavaScript * Verwijdert comments * Verwijdert witruimte Documentatie, voorbeeldcode & online test op http://www.minifier.org Code op https://github.com/matthiasmullie/minify Voor gebruik met composer: https://packagist.org/packages/matthiasmullie/minify

Minify.php
<?php
namespace MatthiasMullie\Minify;

/**
 * Abstract minifier class.
 *
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
 *
 * @author Matthias Mullie <[email protected]>
 *
 * @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 $data
     */
    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) {
            // redefine var
            $data = (string) $data;

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

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

    /**
     * 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 (@file_exists($data) && is_file($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 Exception
     */
    protected function save($content, $path)
    {
        // create file & open for writing
        if (($handler = @fopen($path, 'w')) === false) {
            throw new Exception('The file "' . $path . '" could not be opened. Check if PHP has enough permissions.');
        }

        // write to file
        if (@fwrite($handler, $content) === false) {
            throw new Exception('The file "' . $path . '" could not be written to. Check if PHP has enough permissions.');
        }

        // close the file
        @fclose($handler);
    }

    /**
     * Minify the data.
     *
     * @param  string[optional] $path Path to write the data to.
     * @return string           The minified data.
     */
    abstract public function minify($path = null);

    /**
     * Register a pattern to execute against the source content.
     *
     * @param  string          $pattern     PCRE pattern.
     * @param  string|callable $replacement Replacement value for matched pattern.
     * @throws Exception
     */
    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 = '';

        while ($content) {
            // execute all patterns and find the first match
            $matches = array();
            foreach ($this->patterns as $i => $pattern) {
                list($pattern, $replacement) = $pattern;

                $match = null;
                if (preg_match($pattern, $content, $match)) {
                    $matches[$i] = $match;
                }
            }

            // 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)
            $positions = array();
            foreach ($matches as $i => $match) {
                $positions[$i] = strpos($content, $match[0]);
            }
            $discardLength = min($positions);
            $firstPattern = array_search($discardLength, $positions);
            $match = $matches[$firstPattern][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 = 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;
        }

        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()
     */
    protected function extractStrings()
    {
        // PHP only supports $this inside anonymous functions since 5.4
        $minifier = $this;
        $callback = function ($match) use ($minifier) {
            $count = count($minifier->extracted);
            $placeholder = $match[1] . 'STRING' . $count . $match[1];
            $minifier->extracted[$placeholder] = $match[1] . $match[2] . $match[1];

            return $placeholder;
        };

        $this->registerPattern('/([\'"])(.*?)(?<!\\\\)\\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)
    {
        $content = str_replace(array_keys($this->extracted), $this->extracted, $content);
        $this->extracted = array();
        return $content;
    }
}
CSS.php
<?php
namespace MatthiasMullie\Minify;

/**
 * CSS minifier.
 *
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
 *
 * @author Matthias Mullie <[email protected]>
 * @author Tijs Verkoyen <[email protected]>
 *
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved.
 * @license MIT License
 */
class CSS extends Minify
{
    /**
     * @var int
     */
    protected $maxImportSize = 5;

    /**
     * @var string[]
     */
    protected $importExtensions = array(
        'gif' => 'data:image/gif',
        'png' => 'data:image/png',
        'jpg' => 'data:image/jpg',
        'jpeg' => 'data:image/jpeg',
        'svg' => 'data:image/svg+xml',
        'woff' => 'data:application/x-font-woff',
    );

    /**
     * Set the maximum size if files to be imported.
     *
     * Files larger than this size (in kB) will not be imported into the CSS.
     * Importing files into the CSS as data-uri will save you some connections,
     * but we should only import relatively small decorative images so that our
     * CSS file doesn't get too bulky.
     *
     * @param int $size Size in kB
     */
    public function setMaxImportSize($size) {
        $this->maxImportSize = $size;
    }

    /**
     * Set the type of extensions to be imported into the CSS (to save network
     * connections).
     * Keys of the array should be the file extensions & respective values
     * should be the data type.
     *
     * @param string[] $extensions Array of file extensions
     */
    public function setImportExtensions(array $extensions) {
        $this->importExtensions = $extensions;
    }

    /**
     * Combine CSS from import statements.
     * @import's will be loaded and their content merged into the original file,
     * to save HTTP requests.
     *
     * @param  string $source  The file to combine imports for.
     * @param  string $content The CSS content to combine imports for.
     * @return string
     */
    protected function combineImports($source, $content)
    {
        $importRegexes = array(
            // @import url(xxx)
            '/
            # import statement
            @import

            # whitespace
            \s+

                # open url()
                url\(

                    # (optional) open path enclosure
                    (?P<quotes>["\']?)

                        # fetch path
                        (?P<path>

                            # do not fetch data uris or external sources
                            (?!(
                                ["\']?
                                (data|https?):
                            ))

                            .+?
                        )

                    # (optional) close path enclosure
                    (?P=quotes)

                # close url()
                \)

                # (optional) trailing whitespace
                \s*

                # (optional) media statement(s)
                (?P<media>[^;]*)

                # (optional) trailing whitespace
                \s*

            # (optional) closing semi-colon
            ;?

            /ix',

            // @import 'xxx'
            '/

            # import statement
            @import

            # whitespace
            \s+

                # open path enclosure
                (?P<quotes>["\'])

                    # fetch path
                    (?P<path>

                        # do not fetch data uris or external sources
                        (?!(
                            ["\']?
                            (data|https?):
                        ))

                        .+?
                    )

                # close path enclosure
                (?P=quotes)

                # (optional) trailing whitespace
                \s*

                # (optional) media statement(s)
                (?P<media>[^;]*)

                # (optional) trailing whitespace
                \s*

            # (optional) closing semi-colon
            ;?

            /ix'
        );

        // find all relative imports in css
        $matches = array();
        foreach ($importRegexes as $importRegex) {
            if (preg_match_all($importRegex, $content, $regexMatches, PREG_SET_ORDER)) {
                $matches = array_merge($matches, $regexMatches);
            }
        }

        $search = array();
        $replace = array();

        // loop the matches
        foreach ($matches as $match) {
            // get the path for the file that will be imported
            $importPath = dirname($source) . '/' . $match['path'];

            // only replace the import with the content if we can grab the
            // content of the file
            if (@file_exists($importPath) && is_file($importPath)) {
                // grab referenced file & minify it (which may include importing
                // yet other @import statements recursively)
                $minifier = new static($importPath);
                $importContent = $minifier->minify($source);

                // check if this is only valid for certain media
                if ($match['media']) {
                    $importContent = '@media ' . $match['media'] . '{' . $importContent . '}';
                }

                // add to replacement array
                $search[] = $match[0];
                $replace[] = $importContent;
            }
        }

        // replace the import statements
        $content = str_replace($search, $replace, $content);

        return $content;
    }

    /**
     * Convert relative paths based upon 1 path to another.
     *
     * E.g.
     * ../images/img.gif (relative to /home/forkcms/frontend/core/layout/css)
     * should become:
     * ../../core/layout/images/img.gif (relative to
     * /home/forkcms/frontend/cache/minified_css)
     *
     * @param  string $path The relative path that needs to be converted.
     * @param  string $from The original base path.
     * @param  string $to   The new base path.
     * @return string The new relative path.
     */
    protected function convertRelativePath($path, $from, $to)
    {
        $from = $from ? realpath($from) : '';
        $to = $to ? realpath($to) : '';

        // make sure we're dealing with directories
        $from = @is_file($from) ? dirname($from) : $from;
        $to = @is_file($to) ? dirname($to) : $to;

        // deal with different operating systems' directory structure
        $path = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $path), '/');
        $from = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $from), '/');
        $to = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $to), '/');

        // if we're not dealing with a relative path, just return absolute
        if (strpos($path, '/') === 0) {
            return $path;
        }

        // get full path to file referenced from $from
        $path = $from . '/' . $path;

        /*
         * Example:
         * $path = /home/forkcms/frontend/cache/compiled_templates/../../core/layout/css/../images/img.gif
         * $to = /home/forkcms/frontend/cache/minified_css
         */

        // normalize paths
        do {
            list($path, $to) = preg_replace('/[^\/]+(?<!\.\.)\/\.\.\//', '', array($path, $to), -1, $count);
        } while ($count);

        /*
         * Example:
         * $path = /home/forkcms/frontend/core/layout/images/img.gif
         * $to = /home/forkcms/frontend/cache/minified_css
         */

        $path = explode('/', $path);
        $to = explode('/', $to);

        // compare paths & strip identical ancestors
        foreach ($path as $i => $chunk) {
            if (isset($to[$i]) && $path[$i] == $to[$i]) {
                unset($path[$i], $to[$i]);
            } else {
                break;
            }
        }

        /*
         * At this point:
         * $path = array('core', 'layout', 'images', 'img.gif')
         * $to = array('cache', 'minified_css')
         */

        $path = implode('/', $path);

        // add .. for every directory that needs to be traversed for new path
        $to = str_repeat('../', count($to));

        /*
         * At this point:
         * $path = core/layout/images/img.gif
         * $to = ../../
         */

        // Tada!
        return $to . $path;
    }

    /**
     * Import files into the CSS, base64-ized.
     * @url(image.jpg) images will be loaded and their content merged into the
     * original file, to save HTTP requests.
     *
     * @param  string $source  The file to import files for.
     * @param  string $content The CSS content to import files for.
     * @return string
     */
    protected function importFiles($source, $content)
    {
        $extensions = array_keys($this->importExtensions);
        $regex = '/url\((["\']?)((?!["\']?data:).*?\.(' . implode('|', $extensions) . '))\\1\)/i';
        if ($extensions && preg_match_all($regex, $content, $matches, PREG_SET_ORDER)) {
            $search = array();
            $replace = array();

            // loop the matches
            foreach ($matches as $match) {
                // get the path for the file that will be imported
                $path = $match[2];
                $path = dirname($source) . '/' . $path;
                $extension = $match[3];

                // only replace the import with the content if we're able to get
                // the content of the file, and it's relatively small
                $import = @file_exists($path);
                $import = $import && is_file($path);
                $import = $import && filesize($path) <= $this->maxImportSize * 1024;
                if (!$import) {
                    continue;
                }

                // grab content && base64-ize
                $importContent = $this->load($path);
                $importContent = base64_encode($importContent);

                // build replacement
                $search[] = $match[0];
                $replace[] = 'url(' . $this->importExtensions[$extension] . ';base64,' . $importContent  . ')';
            }

            // replace the import statements
            $content = str_replace($search, $replace, $content);
        }

        return $content;
    }

    /**
     * Minify the data.
     * Perform CSS optimizations.
     *
     * @param  string[optional] $path Path to write the data to.
     * @return string           The minified data.
     */
    public function minify($path = null)
    {
        $content = '';

        // loop files
        foreach ($this->data as $source => $css) {
            // if we'll save to a new path, we'll have to fix the relative paths
            if ($source !== 0) {
                $css = $this->move($source, $path, $css);
            }

            // combine css
            $content .= $css;
        }

        /*
         * Let's first take out strings & comments, since we can't just remove
         * whitespace anywhere. If whitespace occurs inside a string, we should
         * leave it alone. E.g.:
         * p { content: "a   test" }
         */
        $this->extractStrings();
        $this->stripComments();
        $content = $this->replace($content);

        $content = $this->stripWhitespace($content);
        $content = $this->shortenHex($content);
        $content = $this->shortenZeroes($content);

        // restore the string we've extracted earlier
        $content = $this->restoreExtractedData($content);

        $content = $this->importFiles($path, $content);
        $content = $this->combineImports($path, $content);

        // save to path
        if ($path !== null) {
            $this->save($content, $path);
        }

        return $content;
    }

    /**
     * Moving a css file should update all relative urls.
     * Relative references (e.g. ../images/image.gif) in a certain css file,
     * will have to be updated when a file is being saved at another location
     * (e.g. ../../images/image.gif, if the new CSS file is 1 folder deeper)
     *
     * @param  string $source      The file to update relative urls for.
     * @param  string $destination The path the data will be written to.
     * @param  string $content     The CSS content to update relative urls for.
     * @return string
     */
    protected function move($source, $destination, $content)
    {
        /*
         * Relative path references will usually be enclosed by url(). @import
         * is an exception, where url() is not necessary around the path (but is
         * allowed).
         * This *could* be 1 regular expression, where both regular expressions
         * in this array are on different sides of a |. But we're using named
         * patterns in both regexes, the same name on both regexes. This is only
         * possible with a (?J) modifier, but that only works after a fairly
         * recent PCRE version. That's why I'm doing 2 separate regular
         * expressions & combining the matches after executing of both.
         */
        $relativeRegexes = array(
            // url(xxx)
            '/
            # open url()
            url\(

                # open path enclosure
                (?P<quotes>["\'])?

                    # fetch path
                    (?P<path>

                        # do not fetch data uris or external sources
                        (?!(
                            ["\']?
                            (data|https?):
                        ))

                        .+?
                    )

                # close path enclosure
                (?(quotes)(?P=quotes))

            # close url()
            \)

            /ix',

            // @import "xxx"
            '/
            # import statement
            @import

            # whitespace
            \s+

                # we don\'t have to check for @import url(), because the
                # condition above will already catch these

                # open path enclosure
                (?P<quotes>["\'])

                    # fetch path
                    (?P<path>

                        # do not fetch data uris or external sources
                        (?!(
                            ["\']?
                            (data|https?):
                        ))

                        .+?
                    )

                # close path enclosure
                (?P=quotes)

            /ix'
        );

        // find all relative urls in css
        $matches = array();
        foreach ($relativeRegexes as $relativeRegex) {
            if (preg_match_all($relativeRegex, $content, $regexMatches, PREG_SET_ORDER)) {
                $matches = array_merge($matches, $regexMatches);
            }
        }

        $search = array();
        $replace = array();

        // loop all urls
        foreach ($matches as $match) {
            // determine if it's a url() or an @import match
            $type = (strpos($match[0], '@import') === 0 ? 'import' : 'url');

            // fix relative url
            $url = $this->convertRelativePath($match['path'], dirname($source), dirname($destination));

            // build replacement
            $search[] = $match[0];
            if ($type == 'url') {
                $replace[] = 'url(' . $url . ')';
            } elseif ($type == 'import') {
                $replace[] = '@import "' . $url . '"';
            }
        }

        // replace urls
        $content = str_replace($search, $replace, $content);

        return $content;
    }

    /**
     * Shorthand hex color codes.
     * #FF0000 -> #F00
     *
     * @param  string $content The CSS content to shorten the hex color codes for.
     * @return string
     */
    protected function shortenHex($content)
    {
        $content = preg_replace('/(?<![\'"])#([0-9a-z])\\1([0-9a-z])\\2([0-9a-z])\\3(?![\'"])/i', '#$1$2$3', $content);

        return $content;
    }

    /**
     * Shorthand 0 values to plain 0, instead of e.g. -0em.
     *
     * @param  string $content The CSS content to shorten the zero values for.
     * @return string
     */
    protected function shortenZeroes($content)
    {
        $content = preg_replace('/(?<![0-9])-?0(%|px|em)?/i', '0', $content);

        return $content;
    }

    /**
     * Strip comments from source code.
     */
    protected function stripComments()
    {
        $this->registerPattern('/\/\*.*?\*\//s', '');
    }

    /**
     * Strip whitespace.
     *
     * @param  string $content The CSS content to strip the whitespace for.
     * @return string
     */
    protected function stripWhitespace($content)
    {
        // remove leading & trailing whitespace
        $content = preg_replace('/^\s*/m', '', $content);
        $content = preg_replace('/\s*$/m', '', $content);

        // replace newlines with a single space
        $content = preg_replace('/\s+/', ' ', $content);

        // remove whitespace around meta characters
        // inspired by stackoverflow.com/questions/15195750/minify-compress-css-with-regex
        $content = preg_replace('/\s*([\*$~^|]?+=|[{};,>~]|!important\b)\s*/', '$1', $content);
        $content = preg_replace('/([\[(:])\s+/', '$1', $content);
        $content = preg_replace('/\s+([\]\)])/', '$1', $content);
        $content = preg_replace('/\s+(:)(?![^\}]*\{)/', '$1', $content);

        // whitespace around + and - can only be stripped in selectors, like
        // :nth-child(3+2n), not in things like calc(3px + 2px) or shorthands
        // like 3px -2px
        $content = preg_replace('/\s*([+-])\s*(?=[^}]*{)/', '$1', $content);

        // remove semicolon/whitespace followed by closing bracket
        $content = preg_replace('/;}/', '}', $content);

        return trim($content);
    }
}
Exception.php
<?php
namespace MatthiasMullie\Minify;

/**
 * @author Matthias Mullie <[email protected]>
 */
class Exception extends \Exception
{
}
JS.php
<?php
namespace MatthiasMullie\Minify;

/**
 * JavaScript minifier.
 *
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
 *
 * @author Matthias Mullie <[email protected]>
 * @author Tijs Verkoyen <[email protected]>
 *
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved.
 * @license MIT License
 */
class JS extends Minify
{
    /**
     * List of JavaScript operators that accept a <variable, value, ...> after
     * them. We'll insert semicolons if they're missing at EOL, but some
     * end of lines are not the end of a statement, like with these operators.
     *
     * Note: Most operators are fine, we've only removed !, ++ and --.
     * There can't be a newline separating ! and whatever it is negating.
     * ++ & -- have to be joined with the value they're in-/decrementing.
     *
     * Will be loaded from /data/js/operators_before.txt
     *
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Expressions_and_Operators
     * @var string[]
     */
    protected $operatorsBefore = array();

    /**
     * List of JavaScript operators that accept a <variable, value, ...> before
     * them. We'll insert semicolons if they're missing at EOL, but some end of
     * lines are not the end of a statement, like when continued by one of these
     * operators on the newline.
     *
     * Note: Most operators are fine, we've only removed ), ], ++ and --.
     * ++ & -- have to be joined with the value they're in-/decrementing.
     * ) & ] are "special" in that they have lots or usecases. () for example
     * is used for function calls, for grouping, in if () and for (), ...
     *
     * Will be loaded from /data/js/operators_after.txt
     *
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Expressions_and_Operators
     * @var string[]
     */
    protected $operatorsAfter = array();

    /**
     * List of JavaScript reserved words that accept a <variable, value, ...>
     * after them. We'll insert semicolons if they're missing at EOL, but some
     * end of lines are not the end of a statement, like with these keywords.
     *
     * E.g.: we shouldn't insert a ; after this else
     * else
     *     console.log('this is quite fine')
     *
     * Will be loaded from /data/js/reserved_before.txt
     *
     * @see https://mathiasbynens.be/notes/reserved-keywords
     * @var string[]
     */
    protected $keywordsBefore = array();

    /**
     * List of JavaScript reserved words that accept a <variable, value, ...>
     * before them. We'll insert semicolons if they're missing at EOL, but some
     * end of lines are not the end of a statement, like when continued by one
     * of these keywords on the newline.
     *
     * E.g.: we shouldn't insert a ; before this instanceof
     * variable
     *     instanceof String
     *
     * Will be loaded from /data/js/reserved_after.txt
     *
     * @see https://mathiasbynens.be/notes/reserved-keywords
     * @var string[]
     */
    protected $keywordsAfter = array();

    /**
     * {@inheritDoc}
     */
    public function __construct()
    {
        call_user_func_array(array('parent', '__construct'), func_get_args());

        $dataDir = __DIR__ . '/../data/js/';
        $options = FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES;
        $this->keywordsBefore = file($dataDir . 'keywords_before.txt', $options);
        $this->keywordsAfter = file($dataDir . 'keywords_after.txt', $options);
        $this->operatorsBefore = file($dataDir . 'operators_before.txt', $options);
        $this->operatorsAfter = file($dataDir . 'operators_after.txt', $options);
    }

    /**
     * Minify the data.
     * Perform JS optimizations.
     *
     * @param  string[optional] $path Path to write the data to.
     * @return string           The minified data.
     */
    public function minify($path = null)
    {
        $content = '';

        // loop files
        foreach ($this->data as $source => $js) {
            // combine js (separate sources with semicolon)
            $content .= $js . ';';
        }

        /*
         * Let's first take out strings, comments and regular expressions.
         * All of these can contain JS code-like characters, and we should make
         * sure any further magic ignores anything inside of these.
         *
         * Consider this example, where we should not strip any whitespace:
         * var str = "a   test";
         *
         * Comments will be removed altogether, strings and regular expressions
         * will be replaced by placeholder text, which we'll restore later.
         */
        $this->extractStrings();
        $this->stripComments();
        $this->extractRegex();
        $content = $this->replace($content);

        $content = $this->stripWhitespace($content);

        /*
         * Earlier, we extracted strings & regular expressions and replaced them
         * with placeholder text. This will restore them.
         */
        $content = $this->restoreExtractedData($content);

        // save to path
        if ($path !== null) {
            $this->save($content, $path);
        }

        return $content;
    }

    /**
     * Strip comments from source code.
     */
    protected function stripComments()
    {
        // single-line comments
        $this->registerPattern('/\/\/.*$[\r\n]*/m', '');

        // multi-line comments
        $this->registerPattern('/\/\*.*?\*\//s', '');
    }

    /**
     * JS kan have /-delimited regular expressions, like: /ab+c/.match(string)
     *
     * The content inside the regex can contain characters that may be confused
     * for JS code: e.g. it could contain whitespace it needs to match & we
     * don't want to strip whitespace in there.
     *
     * The regex can be pretty simple: we don't have to care about comments,
     * (which also use slashes) because stripComments() will have stripped those
     * already.
     *
     * This method will replace all string content with simple REGEX#
     * placeholder text, so we've rid all regular expressions from characters
     * that may be misinterpreted. Original regex content will be saved in
     * $this->extracted and after doing all other minifying, we can restore the
     * original content via restoreRegex()
     */
    protected function extractRegex()
    {
        // PHP only supports $this inside anonymous functions since 5.4
        $minifier = $this;
        $callback = function ($match) use ($minifier) {
            $count = count($minifier->extracted);
            $placeholder = '/REGEX' . $count . '/';
            $minifier->extracted[$placeholder] = '/' . $match[1] . '/';

            return $placeholder;
        };

        // it's a regex if we can find an opening (not preceded by variable,
        // value or similar) & (non-escaped) closing /,
        $before = $this->getOperatorsForRegex($this->operatorsBefore, '/');
        $this->registerPattern('/^\s*\K\/(.*?(?<!\\\\)(\\\\\\\\)*)\//', $callback);
        $this->registerPattern('/(?:' . implode('|', $before) . ')\s*\K\/(.*?(?<!\\\\)(\\\\\\\\)*)\//', $callback);
    }

    /**
     * Strip whitespace.
     *
     * We won't strip *all* whitespace, but as much as possible. The thing that
     * we'll preserve are newlines we're unsure about.
     * JavaScript doesn't require statements to be terminated with a semicolon.
     * It will automatically fix missing semicolons with ASI (automatic semi-
     * colon insertion) at the end of line causing errors (without semicolon.)
     *
     * Because it's sometimes hard to tell if a newline is part of a statement
     * that should be terminated or not, we'll just leave some of them alone.
     *
     * @param  string $content The content to strip the whitespace for.
     * @return string
     */
    protected function stripWhitespace($content)
    {
        // uniform line endings, make them all line feed
        $content = str_replace(array("\r\n", "\r"), "\n", $content);

        // collapse all non-line feed whitespace into a single space
        $content = preg_replace('/[^\S\n]+/', ' ', $content);

        // strip leading & trailing whitespace
        $content = str_replace(array(" \n", "\n "), "\n", $content);

        // collapse consecutive line feeds into just 1
        $content = preg_replace('/\n+/', "\n", $content);

        // strip whitespace that ends in (or next line begin with) an operator
        // that allows statements to be broken up over multiple lines
        $before = $this->getOperatorsForRegex($this->operatorsBefore, '/');
        $after = $this->getOperatorsForRegex($this->operatorsAfter, '/');
        $content = preg_replace('/(' . implode('|', $before) . ')\s+/', '\\1', $content);
        $content = preg_replace('/\s+(' . implode('|', $after) . ')/', '\\1', $content);

        // make sure + and - can't be mistaken for, or joined into ++ and --
        $content = preg_replace('/(?<![\+\-])\s*([\+\-])/', '\\1', $content);
        $content = preg_replace('/([\+\-])\s*(?!\\1)/', '\\1', $content);

        // collapse whitespace around reserved words into single space
        $before = $this->getKeywordsForRegex($this->keywordsBefore, '/');
        $after = $this->getKeywordsForRegex($this->keywordsAfter, '/');
        $content = preg_replace('/(' . implode('|', $before) . ')\s+/', '\\1 ', $content);
        $content = preg_replace('/\s+(' . implode('|', $after) . ')/', ' \\1', $content);

        /*
         * We didn't strip whitespace after a couple of operators because they
         * could be used in different contexts and we can't be sure it's ok to
         * strip the newlines. However, we can safely strip any non-line feed
         * whitespace that follows them.
         */
        $operators = $this->getOperatorsForRegex($this->operatorsBefore + $this->operatorsAfter, '/');
        $content = preg_replace('/([\}\)\]])[^\S\n]+(?!' . implode('|', $operators) . ')/', '\\1', $content);

        /*
         * We also don't really want to terminate statements followed by closing
         * curly braces (which we've ignored completely up until now): ASI will
         * kick in here & we're all about minifying.
         */
        $content = preg_replace('/;\}/s', '}', $content);

        // get rid of remaining whitespace af beginning/end, as well as
        // semicolon, which doesn't make sense there: ASI will kick in here too
        return trim($content, "\n ;");
    }

    /**
     * We'll strip whitespace around certain operators with regular expressions.
     * This will prepare the given array by escaping all characters.
     *
     * @param  string[] $operators
     * @param  string   $delimiter
     * @return string[]
     */
    protected function getOperatorsForRegex(array $operators, $delimiter = '/')
    {
        // escape operators for use in regex
        $delimiter = array_fill(0, count($operators), $delimiter);
        $escaped = array_map('preg_quote', $operators, $delimiter);

        $operators = array_combine($operators, $escaped);

        // ignore + & - for now, they'll get special treatment
        unset($operators['+'], $operators['-']);

        // dot can not just immediately follow a number; it can be confused
        // between decimal point, or calling a method on it, e.g. 42 .toString()
        $operators['.'] = '(?<![0-9]\s)\.';

        return $operators;
    }

    /**
     * We'll strip whitespace around certain keywords with regular expressions.
     * This will prepare the given array by escaping all characters.
     *
     * @param  string[] $keywords
     * @param  string   $delimiter
     * @return string[]
     */
    protected function getKeywordsForRegex(array $keywords, $delimiter = '/')
    {
        // escape keywords for use in regex
        $delimiter = array_fill(0, count($keywords), $delimiter);
        $escaped = array_map('preg_quote', $keywords, $delimiter);

        // add word boundaries
        array_walk($keywords, function ($value) {
            return '\b' . $value . '\b';
        });

        $keywords = array_combine($keywords, $escaped);

        return $keywords;
    }
}

Reacties

0
Nog geen reacties.