Automatic JavaScript and CSS Packer

By Matt Curry aka "mattc"
Another JavaScript/CSS combiner and packer was recently posted to the Bakery. I felt it was a little configuration heavy and required too much manual intervention. This version is much more automated and requires virtually no code changes.

Notes


  • JavaScript packing only works with PHP5. If you're using PHP4 the scripts will still be merged into one file, but not packed.
  • A sample .htaccess file is included in the zip. Rename it and drop it in js/packed and css/packed for improved performance.


Download


A zip of the code and .htaccess can be found here: http://sandbox2.pseudocoder.com/demo/asset, as well as posted below.

Instructions


  1. You'll need a working version of CakePHP 1.2 installed.
  2. Download jsmin 1.1.0 (http://code.google.com/p/jsmin-php/) or later and put it in vendors/jsmin.
  3. Download CSSTidy 1.3 (http://csstidy.sourceforge.net/) or later and put the contents in vendors/csstidy.
  4. Download/copy+paste the helper and unzip it to app/views/helpers.
  5. Include the helper in any controller that will need it. Most likely you will put it in AppController so that it's available to all your controllers: Download code var $helpers = array('Asset');  
  6. In your layout and views include JavaScript files as you normally would when using the $scripts_for_layout approach: Download code $javascript->link('jquery', false);  
  7. Similarly include your css with the inline option set to false: Download code $html->css('style', null, null, false);
  8. Then in your layout file, in the head section, instead of using $scripts_for_layout call the helper: Download code echo $asset->scripts_for_layout();  


Tips


  • Remember to set the inline option to false for JS and CSS in your layout if you want them to be packed with the view scripts.
  • Setting DEBUG on will cause this helper to output the scripts the same way $scripts_for_layout would, effectifly turning it off while testing.
  • If you get a JavaScript error with a packed version of a file it's most likely missing a semi-colon somewhere.
  • Order is important. If you include script1 then script2 on one view and script2 then script1 on another, they will generate separate packed versions and will be treated by the browser as separate scripts.


Helper Class:

Download code <?php 
/*
 * Asset Packer CakePHP Component
 * Copyright (c) 2008 Matt Curry
 * www.PseudoCoder.com
 * http://www.pseudocoder.com/archives/2007/08/08/automatic-asset-packer-cakephp-helper
 *
 * @author      mattc <matt@pseudocoder.com>
 * @version     1.2
 * @license     MIT
 *
 */

class AssetHelper extends Helper {
  
//Cake debug = 0                          packed js/css returned.  $this->debug doesn't do anything.
  //Cake debug > 0, $this->debug = false    essentially turns the helper off.  js/css not packed.  Good for debugging your js/css files.
  //Cake debug > 0, $this->debug = true     packed js/css returned.  Good for debugging this helper.
  
var $debug false;

  
//there is a *minimal* perfomance hit associated with looking up the filemtimes
  //if you clean out your cached dir (as set below) on builds then you don't need this.
  
var $checkTS true;

  
//the packed files are named by stringing together all the individual file names
  //this can generate really long names, so by setting this option to true
  //the long name is md5'd, producing a resonable length file name.
  
var $md5FileName false;

  
//you can change this if you want to store the files in a different location.
  //this is relative to your webroot/js and webroot/css paths
  
var $cachePath 'packed/';

  
//set the css compression level
  //options: default, low_compression, high_compression, highest_compression
  //default is no compression
  //I like high_compression because it still leaves the file readable.
  
var $cssCompression 'high_compression';

  var 
$helpers = array('Html''Javascript');
  var 
$viewScriptCount 0;

  
//flag so we know the view is done rendering and it's the layouts turn
  
function afterRender() {
    
$view =& ClassRegistry::getObject('view');
    
$this->viewScriptCount count($view->__scripts);
  }

  function 
scripts_for_layout() {
    
$view =& ClassRegistry::getObject('view');

    
//nothing to do
    
if (!$view->__scripts) {
      return;
    }

    
//move the layout scripts to the front
    
$view->__scripts array_merge(
                         
array_slice($view->__scripts$this->viewScriptCount),
                         
array_slice($view->__scripts0$this->viewScriptCount)
                       );


    if (
Configure::read('debug') && $this->debug == false) {
      return 
join("\n\t"$view->__scripts);
    }

    
//split the scripts into js and css
    
foreach ($view->__scripts as $i => $script) {
      if (
preg_match('/js\/(.*).js/'$script$match)) {
        
$temp = array();
        
$temp['script'] = $match[1];
        
$temp['name'] = basename($match[1]);
        
$js[] = $temp;

        
//remove the script since it will become part of the merged script
        
unset($view->__scripts[$i]);
      } else if (
preg_match('/css\/(.*).css/'$script$match)) {
        
$temp = array();
        
$temp['script'] = $match[1];
        
$temp['name'] = basename($match[1]);
        
$css[] = $temp;

        
//remove the script since it will become part of the merged script
        
unset($view->__scripts[$i]);
      }
    }

    
$scripts_for_layout '';
    
//first the css
    
if (!empty($css)) {
      
$scripts_for_layout .= $this->Html->css($this->cachePath $this->process('css'$css));
      
$scripts_for_layout .= "\n\t";
    }

    
//then the js
    
if (!empty($js)) {
      
$scripts_for_layout .= $this->Javascript->link($this->cachePath $this->process('js'$js));
    }

    
//finally anything that was left over, usually codeBlocks
    
$scripts_for_layout .= join("\n\t"$view->__scripts);

    return 
$scripts_for_layout;
  }


  function 
process($type$data) {
    switch (
$type) {
      case 
'js':
        
$path JS;
        break;
      case 
'css':
        
$path CSS;
        break;
    }

    
$folder = new Folder;

    
//make sure the cache folder exists
    
if ($folder->create($path $this->cachePath"777")) {
      
trigger_error('Could not create ' $path $this->cachePath
                    
'. Please create it manually with 777 permissions'E_USER_WARNING);
    }

    
//check if the cached file exists
    
$names Set::extract($data'{n}.name');
    
$folder->cd($path $this->cachePath);
    
$fileName $folder->find($this->__generateFileName($names) . '_([0-9]{10}).' $type);
    if (
$fileName) {
      
//take the first file...really should only be one.
      
$fileName $fileName[0];
    }

    
//make sure all the pieces that went into the packed script
    //are OLDER then the packed version
    
if ($this->checkTS && $fileName) {
      
$packed_ts filemtime($path $this->cachePath $fileName);

      
$latest_ts 0;
      
$scripts Set::extract($data'{n}.script');
      foreach(
$scripts as $script) {
        
$latest_ts max($latest_tsfilemtime($path $script '.' $type));
      }

      
//an original file is newer.  need to rebuild
      
if ($latest_ts $packed_ts) {
        
unlink($path $this->cachePath $fileName);
        
$fileName null;
      }
    }

    
//file doesn't exist.  create it.
    
if (!$fileName) {
      
$ts time();

      
//merge the script
      
$scriptBuffer '';
      
$scripts Set::extract($data'{n}.script');
      foreach(
$scripts as $script) {
        
$buffer file_get_contents($path $script '.' $type);

        switch (
$type) {
          case 
'js':
            
//jsmin only works with PHP5
            
if (PHP5) {
              
vendor('jsmin/jsmin');
              
$buffer trim(JSMin::minify($buffer));
            }
            break;

          case 
'css':
            
vendor('csstidy/class.csstidy');
            
$tidy = new csstidy();
            
$tidy->load_template($this->cssCompression);
            
$tidy->parse($buffer);
            
$buffer $tidy->print->plain();
            break;
        }

        
$scriptBuffer .= "\n/* $script.$type */\n" $buffer;
      }


      
//write the file
      
$fileName $this->__generateFileName($names) . '_' $ts '.' $type;
      
$file = new File($path $this->cachePath $fileName);
      
$file->write(trim($scriptBuffer));
    }

    if (
$type == 'css') {
      
//$html->css doesn't check if the file already has
      //the .css extension and adds it automatically, so we need to remove it.
      
$fileName str_replace('.css'''$fileName);
    }

    return 
$fileName;
  }

  function 
__generateFileName($names) {
    
$fileName str_replace('.''-'implode('_'$names));

    if (
$this->md5FileName) {
      
$fileName md5($fileName);
    }

    return 
$fileName;
  }
}
?>

Comments 493

CakePHP team comments Author comments

Comment

1 Nicely Done

So easy, nicely done.
posted Mon, Aug 20th 2007, 19:23 by Paul Webster

Bug

2 DEBUG usage in the latest revisions and .htaccess file

Change DEBUG on line 51 to Configure::read('debug') as we are moving to using Configure class instead of constants :)

also i use this .htaccess file:

<IfModule mod_deflate.c>
    # compress content with type html, text, and css
    AddOutputFilterByType DEFLATE text/css text/javascript application/x-javascript text/js
    <ifmodule mod_headers.c>
      # properly handle requests coming from behind proxies
      Header append Vary User-Agent
    </ifmodule>
</IfModule>

<IfModule mod_expires.c> 
    ExpiresActive On
    ExpiresByType text/css "access plus 10 years"
    ExpiresByType text/js "access plus 10 years"
    ExpiresByType text/javascript "access plus 10 years"
    ExpiresByType application/x-javascript "access plus 10 years"
    ExpiresByType image/png "access plus 10 years"
</IfModule>

FileETag none


Great work! thanks
posted Sun, Sep 23rd 2007, 14:00 by Marcin Domanski

Comment

3 Not working for me

In the header section of the layout, I have the following:
http://bin.cakephp.org/view/1193049833

Unfortunately, the files are not merged, nor are they minified.

Any hints why ?
posted Sun, Dec 9th 2007, 06:40 by seme

Comment

4 New Version

Hey All,
There is a new version of this helper at:
http://sandbox2.pseudocoder.com/demo/asset.

Biggest change is the use of the new Configure::read('debug') syntax.
posted Mon, Dec 10th 2007, 14:06 by Matt Curry

Comment

5 CSS compression

I think copression of CSS should precessed separetely, helper shuld allow to copress 'js', 'css', or both.

Another problem - folder for css files should not changed or we need move compressed file to 'webroot/ccss' folder.
This is requre of desiners that want use relative paths to img folder. After creating css asset in /css/packed paths are brokened.
posted Wed, Dec 12th 2007, 03:57 by Yevgeny Tomenko

Comment

6 Helper using this as a base

I created, "Asset Mapper" using this script as a base to build on top of:
http://bakery.cakephp.org/articles/view/asset-mapper
posted Fri, Dec 28th 2007, 05:29 by Marc Grabanski

Comment

7 Modification for theming

Firstly, thanks to Matt for taking the time and effort to post such a generally useful helper. I vote to include it in the cake core.

Anyways, my app uses cake 1.2's theming capability, and the asset helper as it currently exists (version 1.2 of the helper) doesn't support this. In particular, it doesn't first check the webroot/themed/ directory for the themed versions of the css/js files.

So, I made a couple of trivial modifications to the process() function in the helper.

Here it is (sorry, my comments are also included in the code - feel free to delete them).


<?php 
function process($type$data) {
    switch (
$type) {
      case 
'js':
        
$path JS;
        
$themedPath $this->webroot.$this->themeWeb.'js/';
        break;
      case 
'css':
        
$path CSS;
        
$themedPath WWW_ROOT.$this->themeWeb.'css/';
        break;
    }
    
$folder = new Folder;
    
    
//make sure the cache folder 'packed/' exists
    
if ($folder->create($path $this->cachePath"777")) {
      
trigger_error('Could not create ' $path $this->cachePath
                   
'. Please create it manually with 777 permissions'E_USER_WARNING);
    }
    
    
//Does a cached packed file exist?
    
    
$names Set::extract($data'{n}.name');
    
$folder->cd($path $this->cachePath);
    
$fileName $folder->find($this->__generateFileName($names) . '_([0-9]{10}).' $type);
    if (
$fileName) {
      
//Cached packed file exists. Take the first one...really should only be one, 
      // because we delete them, before we create another one.
      
$fileName $fileName[0];
    }
    
    
// Make sure all the components that made up the packed file
    // are OLDER then the packed version
    
    
if ($this->checkTS && $fileName) {
        
        
// Get the filetime of the packed file.
      
$packed_ts filemtime($path $this->cachePath $fileName);

      
// get the newest filetime of all the components.
      
$latest_ts 0;
      
$scripts Set::extract($data'{n}.script');
      foreach(
$scripts as $script) {
          if (
is_file($themedPath $script '.' $type)){
              
$latest_ts max($latest_tsfilemtime($themedPath $script '.' $type));
          }else{
              
$latest_ts max($latest_tsfilemtime($path $script '.' $type));
          }
      }

      
// Are any of the components newer? If so, need to rebuild the packed file, so first delete it.
      
if ($latest_ts $packed_ts) {
        
unlink($path $this->cachePath $fileName);
        
$fileName null;
      }
    }
    
    
//file didn't exist, or it did, but we deleted it because it needs to be rebuilt. Build it.
    
if (!$fileName) {
      
$ts time();

      
$scriptBuffer '';
      
$scripts Set::extract($data'{n}.script');
      foreach(
$scripts as $script) {
          
          
// first try the themed path to see if the file exists.
        
if (is_file($themedPath $script '.' $type)){
            
// file exists in themed path.
            
$buffer file_get_contents($themedPath $script '.' $type);
        }else{
            
// nothing in themed path, use the default path.
            
$buffer file_get_contents($path $script '.' $type);
        }

        switch (
$type) {
          case 
'js':
            
//jsmin only works with PHP5
            
if (PHP5) {
              
vendor('jsmin/jsmin');
              
$buffer trim(JSMin::minify($buffer));
            }
            break;

          case 
'css':
            
vendor('csstidy/class.csstidy');
            
$tidy = new csstidy();
            
$tidy->load_template($this->cssCompression);
            
$tidy->parse($buffer);
            
$buffer $tidy->print->plain();
            break;
        }

        
$scriptBuffer .= "\n/* $script.$type */\n" $buffer;
      }


      
//write the packed file to the default path. Reason we don't write to the 
      // themed path is because some of the files may have come from the theme, but some
      // may be generic to all apps and may have come from the default path.
      // So we just write the packed file containining everything to the default path.
      
$fileName $this->__generateFileName($names) . '_' $ts '.' $type;
      
$file = new File($path $this->cachePath $fileName);
      
$file->write(trim($scriptBuffer));
    }
    
    if (
$type == 'css') {
      
//$html->css doesn't check if the file already has
      //the .css extension and adds it automatically, so we need to remove it.
      
$fileName str_replace('.css'''$fileName);
    }
    
    return 
$fileName;
  }
?>
posted Wed, Feb 27th 2008, 06:32 by keymaster

Bug

8 line 45 error

There was a slight error w/ debug 2 when using the email component @ line 45.

I had to change


$this->viewScriptCount = count($view->__scripts);


to


if(isset($view->__scripts) && !empty($view->__scripts)) {
  $this->viewScriptCount = count($view->__scripts);
}
posted Mon, Apr 28th 2008, 18:11 by Chad

Login to Submit a Comment