PHP: Combine and compile CSS and JS into Gzipped files

This has actually been the secret behind a lot of our great system performance, but as it was open source originally, it's going back to the people. Its a relatively straightforward script and speeds up JS and CSS performance by a huge amount, especially if you are using multiple files (like we do!).

It should be noted that the compression and combining engine is standard on all hosting accounts - just skip straight to step 5.

The red settings represent paths that need to be set dependant on where you put the files.

It is based on the combine script by Rakaz (http://rakaz.nl/item/make_your_pages_load_faster_by_combining_and_compressing_javascript_and_css_files), but there were notable flaws in that version.

  • gZip encoding was not handled correctly by the browser
  • cache files build up (requires cron to remove routinely)
  • content echo'ing and incorrect headers causing slowdowns

Our version is about 100-200% faster than the original build, we thank Rakaz for giving us the chance to build it.

New features include:

  • automatic update of cache files when a file has been changed
  • automatic generation of gzipped and non gzipped files (for browsers which do not support gzip)
  • optimised headers
  • independent JavaScript library support
  • no CPU overhead of gzipping on the fly as the gzipped version is permanently stored the first time a visitor hits a page which hasn't been cached
Get a feel for the difference here:

(left) gZipped JavaScript includes: https://www.sonassi.com/index/

(right) Non-gzip JavaScript includes: https://www.sonassi.com/index-dummy/

Lets get going then

Pre-requisites: Enable mod_rewrite
# a2enmod rewrite
# apache2ctl restart
And mod_expires
# a2enmod expires
# apache2ctl restart
And mod_deflate
# a2enmod deflate
# apache2ctl restart

You need to edit a few files for the next stage:

apache2.conf
.htaccess
combine-gzip.inc.php

1 ) In the apache2.conf or .htaccess, add the following lines

RewriteRule ^/gzipjs/(.*.js) <span style="color: #ff0000;">/data/lib/php/combine-gzip.inc.php</span>?type=javascript&encoding=gzip&files=$1
RewriteRule ^/gzipcss/(.*.css) <span style="color: #ff0000;">/data/lib/php/combine-gzip.inc.php</span>?type=css&encoding=gzip&files=$1
 
<FilesMatch ".combine-cache.gzip$">
  RewriteCond %{HTTP_USER_AGENT} ..*Safari.*. [OR]
  RewriteCond %{HTTP:Accept-Encoding} !gzip
  RewriteRule (.*).combine-cache.gzip$ $1.combine-cache [L]
 
  #AddType "text/plain" .gzip
  AddEncoding gzip .gzip
</FilesMatch>
 
<IfModule mod_deflate.c>
  SetEnvIfNoCase Request_URI
    .(?:exe|t?gz|zip|bz2|sit|rar|<strong>gzip</strong>)$
    no-gzip dont-vary
</IfModule>
2 ) For a spot of extra performance, enable mod_expires, and enter this into your apache conf
<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresDefault A300
  ExpiresByType text/html A7200
  ExpiresByType text/javascript A604800
  ExpiresByType application/x-javascript A604800
  ExpiresByType text/css A604800
  ExpiresByType image/x-icon A31536000
  ExpiresByType image/gif A604800
  ExpiresByType image/jpg A604800
  ExpiresByType image/jpeg A604800
  ExpiresByType image/png A604800
  ExpiresByType text/plain A604800
</IfModule>
3) Then create the cache directory in your document_root
# cd <span style="color: #ff0000;">DOCROOT</span>
# mkdir ./cache
# chmod 775 ./cache
4) Create the combine-gzip.inc.php, with the following content
<?php

  /************************************************************************
   * CSS and Javascript Combinator 0.5 + SMS gZip patch
   * Original Author 
   * http://rakaz.nl/item/make_your_pages_load_faster_by_combining_and_compressing_javascript_and_css_files
   * gZip patch by Sonassi Media Services (www.sonassi.com)
   */

  $cache    = true;
  $encoding = 'none';
  $cachedir = <span style="color: #ff0000;">$_SERVER['DOCUMENT_ROOT'] </span>. 'cache';
  $cssdir   = <span style="color: #ff0000;">$_SERVER['DOCUMENT_ROOT'] </span>. 'css';
  $jsdir    = <span style="color: #ff0000;">$_SERVER['DOCUMENT_ROOT']</span> . 'js';
  $allowed_encoding = array('none','gzip','deflate');
  $js_library = '<span style="color: #ff0000;">/data/lib/js</span>'; # No trailing slash

  if(isset($_GET['encoding']) && in_array($_GET['encoding'],$allowed_encoding))
    $encoding = $_GET['encoding'];

  // Determine the directory and type we should use
  switch ($_GET['type']) {
    case 'css':
      $base = realpath($cssdir);
      break;
    case 'javascript':
      $base = realpath($jsdir);
      break;
    default:
      header ("HTTP/1.0 503 Not Implemented");
      exit;
  };

  $type = $_GET['type'];
  $elements = explode(',', $_GET['files']);

  // Determine last modification date of the files
  $lastmodified = 0;
  while (list(,$element) = each($elements)) {
    if (strstr($element,'/lib/')) {
      $element = str_replace('/lib',$js_library,$element);
    } else {
      $element = $base . '/' . $element;
    }
    $path = realpath($element);

    if (($type == 'javascript' && substr($path, -3) != '.js') ||
      ($type == 'css' && substr($path, -4) != '.css' &&
      (substr($path, 0, strlen($base)) != $base || substr($path, 0, strlen($js_library)))
      )) {
      header ("HTTP/1.0 403 Forbidden");
      exit;
    }

    if (!file_exists($path)) {
      header ("HTTP/1.0 404 Not Found");
      exit;
    }

    $lastmodified = max($lastmodified, filemtime($path));
  }

  // Send Etag hash
  $mhash = md5($_GET['files']);
  $hash = $lastmodified . '-' . $mhash;
  header ("Etag: "" . $hash . """);

  if (isset($_SERVER['HTTP_IF_NONE_MATCH']) &&
    stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) == '"' . $hash . '"')
  {
    // Return visit and no modifications, so do not send anything
    header ("HTTP/1.0 304 Not Modified");
    header ('Content-Length: 0');
  }
  else
  {
    // First time visit or files were modified
    if ($cache)
    {
      // Determine supported compression method
      $gzip = strstr($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip');
      $deflate = strstr($_SERVER['HTTP_ACCEPT_ENCODING'], 'deflate');

      // Determine used compression method
      if ($encoding != 'none')
        $encoding = $gzip ? 'gzip' : ($deflate ? 'deflate' : 'none');

      // Check for buggy versions of Internet Explorer
      if (!strstr($_SERVER['HTTP_USER_AGENT'], 'Opera') &&
        preg_match('/^Mozilla/4.0 (compatible; MSIE ([0-9].[0-9])/i', $_SERVER['HTTP_USER_AGENT'], $matches)) {
        $version = floatval($matches[1]);

        if ($version < 6)
          $encoding = 'none';

        if ($version == 6 && !strstr($_SERVER['HTTP_USER_AGENT'], 'EV1'))
          $encoding = 'none';
      }

      // Try the cache first to see if the combined files were already generated
      $plaincachefile = 'cache-' . $hash . '.' . $type . '.combine-cache';
      $cachefile = $plaincachefile . ($encoding != 'none' ? '.' . $encoding : '');

      if (file_exists($cachedir . '/' . $cachefile)) {

        if ($encoding=='gzip') {
          header("HTTP/1.1 301 Moved Permanently");
          header("Cache-Control: access plus 7 days");
          header("Location: /cache/$cachefile");
          header("Connection: close");
          exit;
        } else {
          header("HTTP/1.1 301 Moved Permanently");
          header("Cache-Control: access plus 7 days");
          header("Location: /cache/$plaincachefile");
          header("Connection: close");
          exit;
        }
      }
    }

    // Get contents of the files
    $contents = '';
    reset($elements);
    while (list(,$element) = each($elements)) {
      if (strstr($element,'/lib/')) {
        $element = str_replace('/lib',$js_library,$element);
      } else {
        $element = $base . '/' . $element;
      }

      $path = realpath($element);
      $contents .= "nn" . file_get_contents($path);
    }

    // Send Content-Type
    header ("Content-Type: text/" . $type);

    // Store cache
    if ($cache) {
      if ($encoding != 'none') {
        $gcontents = gzencode("/*nGZIPPED $mhashn*/" . $contents, 9, $gzip ? FORCE_GZIP : FORCE_DEFLATE);

        if ($fp = fopen($cachedir . '/' . $cachefile, 'wb')) {
          fwrite($fp, $gcontents);
          fclose($fp);
        }
      }
      if ($fp = fopen($cachedir . '/' . $plaincachefile, 'wb')) {
        fwrite($fp, $contents);
        fclose($fp);
      }

      foreach (glob($cachedir . "/" . "*" . $type .".combine-cache*") as $filename) {
        if (strstr($filename,$mhash) && !strstr($filename,$cachefile) && !strstr($filename,$plaincachefile))
          @unlink($filename);
      }
    }

    if ($encoding=='gzip') {
      header("HTTP/1.1 301 Moved Permanently");
      header("Location: /cache/$cachefile");
      header("Connection: close");
      exit;
    } else {
      header("HTTP/1.1 301 Moved Permanently");
      header("Location: /cache/$plaincachefile");
      header("Connection: close");
      exit;
    }
  } 

?>
5) Add the path into your tags with comma separated values, Eg.
<<span class="start-tag">link</span><span class="attribute-name"> rel</span>=<span class="attribute-value">"stylesheet" </span><span class="attribute-name">type</span>=<span class="attribute-value">"text/css" </span><span class="attribute-name">href</span>=<span class="attribute-value">"/gzipcss/style.css,nav.css,blog.css" </span><span class="attribute-name">title</span>=<span class="attribute-value">"default" </span><span class="error"><span class="attribute-name">/</span></span>>
<<span class="start-tag">script</span><span class="attribute-name"> type</span>=<span class="attribute-value">"text/javascript" </span><span class="attribute-name">src</span>=<span class="attribute-value">"/gzipjs//lib/jquery.min.js,boss-site-search.js,/lib/superfish.js" /></span>
Then sit back and relax enjoying your MASSIVELY reduced bandwidth and CPU overheads.

For examples of this script in use, check out any page of our website at www.sonassi.com

[syntaxhighlighter]
  • Will this same method work in an Apple machine? I'm running Leopard right now and I'm not sure exactly where mod_rewrite, mod_expire, or mod_deflate settings are located.

  • Nice update to the original script! The removal of the old cache files is a very nice improvement plus the cache expire headers vs the resend as in the old script ... very nice.

    You should post the script above with the ''s embedded since as posted it will not work, ie the Etag and Moz pregmatch are malformed without them.

  • justin

    always seem to keep comin back here to read, think this calls for a bookmark. thanks much

  • Simon

    I'm getting the following error in apache when I try this:

    .htaccess: Missing regular expression for SetEnvIfNoCase

  • Dailce

    Great script but I can't get it to work. Where do I place the php file?
    Do I place it at the top of my files?

    • Hi Dailce,

      You can put the file wherever you like, .htaccess takes care of file loading.

  • Dailce

    Figureout the file thing. But, what is the $js_library refering to? Not sure how to set this up 🙁 Also, the htaccess file is using /data/lib/php/ is this common for all sites? or do I have to ask my hosting company for the path?

    • $js_library is a common location that we use to store all JavaScript library files so our hosting customers can benefit from a large JS catalogue. You can comment those lines out or redirect to your own location.

  • Mihai

    hi. i get "Internal Error" when i modify my .httaccess file at stage 1. 🙁

  • Ayan Debnath

    Can you please help me to implement this for WordPress and Joomla ?

  • Nandkishore

    $cachedir = $_SERVER['DOCUMENT_ROOT'] . 'cache'; ????
    $cssdir = $_SERVER['DOCUMENT_ROOT'] . 'css';???
    $jsdir = $_SERVER['DOCUMENT_ROOT'] . 'js'; ????PHP error should be embeded properly php formate.header ("Etag: "" . $hash . """); should be header ("Etag: "" . $hash . """);