diff --git a/lib/WebGUI/Macro/Build.pm b/lib/WebGUI/Macro/Build.pm
new file mode 100644
index 000000000..834d57863
--- /dev/null
+++ b/lib/WebGUI/Macro/Build.pm
@@ -0,0 +1,478 @@
+package WebGUI::Macro::Build;
+
+=head1 LEGAL
+
+ -------------------------------------------------------------------
+ (c) Patrick Donelan
+ -------------------------------------------------------------------
+ http://patspam.com pat@patspam.com
+ -------------------------------------------------------------------
+
+=cut
+
+use strict;
+use Readonly;
+use File::Assets;
+use CSS::Minifier::XS; # implicit
+use JavaScript::Minifier::XS; # implicit
+use File::Slurp qw(read_file write_file);
+use Digest::SHA1 qw(sha1_hex);
+use JSON;
+
+Readonly my $STATIC => 'static'; # Source files come from uploads/$STATIC
+Readonly my $MINIFIED => 'minified'; # Build dir is uploads/$MINIFIED
+Readonly my $ASSETS => 'assets'; # Built JS/CSS files are called $ASSETS.js/css
+
+=head1 NAME
+
+Package WebGUI::Macro::Minify
+
+=head2 DESCRIPTION
+
+Build tool for maximising YSlow score. CSS and JS are minified into files called assets.css and assets.js in the build dir.
+
+=head3 FOLDERS
+
+Build dir lives under /data/domains/site.com/uploads/$STATIC (so that we don't clash with any existing urls).
+Normally this would be a symlink to your custom static folder.
+You can create a "frozen" folder inside $STATIC and modify site.com.modproxy.conf to give it a far-future expiry:
+
+ ExpiresActive On
+ ExpiresDefault "access plus 10 years"
+
+Any files in this folder should be revved.
+
+You will need to manually build mod_expires and modify modproxy.conf to load the module:
+ LoadModule expires_module modules/mod_expires.so
+
+$MINIFIED folder gets created automatically (it lives under /uploads too).
+You should also give it a far-future expiry:
+
+ ExpiresActive On
+ ExpiresDefault "access plus 10 years"
+
+
+To further maximise your YSlow score, you should make sure modproxy.conf and modperl.conf both contain:
+ LoadModule deflate_module modules/mod_deflate.so
+ AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/x-javascript application/x-shockwave-flash application/javascript
+ FileETag none
+
+=head3 MODES
+
+Modes can be specified either as first argument to macro, or via url as ?build=mode.
+Expects unmixed args, e.g. only css, only js or all generic assets (e.g. images).
+Assets are symlinked to build dir. They can be specified as file globs (e.g. 'subfolder/*.png')
+or as entire dirs (e.g. 'subfolder/subsubfolder').
+Digest cached in wg settings table (see get_digest_field_name).
+Description of modes:
+
+=head4 default mode
+js: re-use previously built $ASSETS.js and cached digest
+css: re-use previously built $ASSETS.css and cached digest
+images: re-use symlinked images
+
+=head4 debug mode
+js: concat into $ASSETS.js and update digest
+css: use original css
+images: symlink images
+
+=head4 min mode
+js: minify into $ASSETS.js and update digest
+css: minify into $ASSETS.css, rewrite CSS image urls and update digest
+images: symlink images
+
+=head3 JS Widget Best Practices
+
+Each widget should be in a sub-dir with js, css and images
+CSS should use local relative path to images
+Images should use unique prefix so that they don't clash with other images when copied to minified folder
+
+=cut
+
+#-------------------------------------------------------------------
+sub process {
+ my ( $session, @args ) = @_;
+
+ if ( !@args ) {
+ $session->log->warn('Minify: no args, skipping');
+ return;
+ }
+
+ my ( $mode, $type, @files ) = process_macro_args( $session, @args );
+
+ if ( $type eq 'js' ) {
+ return handle_js( $session, $mode, @files );
+ }
+ elsif ( $type eq 'css' ) {
+ return handle_css( $session, $mode, @files );
+ }
+ else {
+ return handle_assets( $session, $mode, @files );
+ }
+}
+
+#-------------------------------------------------------------------
+
+=head2 process_macro_args
+Figures out what to do based on the macro args, form params etc..
+=cut
+
+sub process_macro_args {
+ my ( $session, @args ) = @_;
+
+ # Trim whitespace from Macro arguments
+ map {s/^\s+|\s$//g} @args;
+
+ # Get mode and remove any mode-related arguments from @args..
+ my $form_param = $session->form->param('build') || q{};
+ my $mode = 'default';
+ foreach my $valid_mode (qw(min debug)) {
+ if ( $args[0] eq $valid_mode ) {
+ $mode = $valid_mode;
+ shift @args;
+ }
+ elsif ( $form_param eq $valid_mode ) {
+ $mode = $valid_mode;
+ }
+ }
+ my $type = get_type(@args);
+
+ $session->log->debug( "Minify: $mode mode for $type on " . @args . ' files' );
+
+ return ( $mode, $type, @args );
+}
+
+#-------------------------------------------------------------------
+
+=head2 get_type
+Guesses file type (css or js). Anything else (e.g. images) is returns undef.
+=cut
+
+sub get_type {
+ my @files = @_;
+ foreach my $file (@files) {
+ return 'css' if $file =~ /\.css$/i;
+ return 'js' if $file =~ /\.js$/i;
+ }
+ return;
+}
+
+#-------------------------------------------------------------------
+
+=head2 get_digest_field_name ($type)
+Generates the field name used to store the cached digest in the settings table
+=cut
+
+sub get_digest_field_name {
+ my ($type) = @_;
+ return "minify_digest_$type";
+}
+
+#-------------------------------------------------------------------
+
+=head2 get_digest
+Pulls a previously stored digest out of the wg db
+=cut
+
+sub get_digest {
+ my ( $session, $type ) = @_;
+ my $digest = $session->db->quickScalar( 'select value from settings where name = ?',
+ [ get_digest_field_name($type) ] );
+ return $digest;
+}
+
+#-------------------------------------------------------------------
+
+=head2 handle_assets
+Assets are symlinked to build dir. They can be specified as file globs (e.g. 'subfolder/*.png')
+or as entire dirs (e.g. 'subfolder/subsubfolder').
+=cut
+
+sub handle_assets {
+ my ( $session, $mode, @files ) = @_;
+
+ # Check if we need to do anything extra for mode..
+ if ( $mode ne 'default' ) {
+
+ # N.B. Not needed for CSS images in debug mode, but possibly for js and other things
+ link_assets( $session, @files );
+ }
+
+ return; # no output needed for assets
+}
+
+#-------------------------------------------------------------------
+
+=head2 link_assets (@files)
+Symlink files to the build dir
+=cut
+
+sub link_assets {
+ my ( $session, @files ) = @_;
+ my $base_dir = $session->config->get('uploadsPath');
+ my $build_dir = "$base_dir/$MINIFIED";
+
+ my $counter = 0;
+ foreach my $file (@files) {
+ $file =~ s{^[/.]*}{}; # disallow absolute paths
+ $file =~ s{\.\.}{}g; # disallow tree-traversal
+ if ( $file =~ /\*/ ) {
+ $session->log->debug("Processing file glob: $file");
+ while (my $asset = <$base_dir/$STATIC/$file>) { # probably a security hole
+ my $src = $asset;
+ $asset =~ s{.*/}{}; # remove subdirs from path
+ my $dest = "$build_dir/$asset";
+ create_link( $session, $src, $dest );
+ $counter++;
+ }
+ }
+ else {
+ $session->log->debug("Processing asset: $file");
+ my $src = "$base_dir/$STATIC/$file";
+ $file =~ s{.*/}{}; # remove subdirs from path
+ my $dest = "$build_dir/$file";
+ create_link( $session, $src, $dest );
+ $counter++;
+ }
+ }
+ $session->log->debug("Minify: linked $counter assets to: $build_dir");
+ return;
+}
+
+#-------------------------------------------------------------------
+
+=head2 create_link ($src, $dest)
+Create a single symlink from $src to $dest
+=cut
+
+sub create_link {
+ my ( $session, $src, $dest ) = @_;
+ if ( -e $dest && !-l $dest ) {
+ $session->log->error("Destination file exists but is not a symlink: $dest");
+ return;
+ }
+ else {
+ remove( $session, $dest );
+
+ if ( symlink $src, $dest ) {
+ $session->log->debug("Symlinked $src to $dest");
+ return 1;
+ }
+ else {
+ $session->log->error("Unable to symlink $src to $dest: $!");
+ return;
+ }
+ }
+}
+
+#-------------------------------------------------------------------
+
+=head2 handle_css ($mode, @files)
+In debug mode, write out link tags to original (untouched) css files.
+In min mode, minify css to $ASSETS.css, rewrite CSS image urls and update digest.
+In default mode, pluck the digest out of the db and do no work.
+
+This call to the macro should be placed in the HEAD of the document.
+=cut
+
+sub handle_css {
+ my ( $session, $mode, @files ) = @_;
+
+ # Check if we need to minify..
+ if ( $mode eq 'min' ) {
+ minify( $session, $mode, 'css', @files );
+ }
+
+ my $base_uri = $session->config->get('uploadsURL');
+
+ my $output = q{};
+ if ( $mode eq 'debug' ) {
+
+ # Use original css in debug mode
+ foreach my $file (@files) {
+ my $original_file_uri = "$base_uri/$STATIC/$file";
+ $output .= qq~\n~;
+ }
+ }
+ else {
+
+ # Use minified css in both 'min' and 'default' modes
+ my $digest = get_digest( $session, 'css' );
+ my $asset_uri = "$base_uri/$MINIFIED/$ASSETS.css?digest=$digest";
+ $output = qq~~;
+ }
+ return $output;
+}
+
+#----------------------------------------------------------------------------
+
+=head2 handle_js
+
+In debug mode, concat js files into $ASSETS.js and update the digest.
+In min mode, minify js files into $ASSETS.js and update the digest.
+In default mode, pluck the digest out of the db and do no work.
+
+This call to the macro should be placed in the bottom of the BODY of the document
+
+=cut
+
+sub handle_js {
+ my ( $session, $mode, @files ) = @_;
+
+ # Check if we need to minify
+ if ( $mode ne 'default' ) { # Minify in both 'min' and 'debug' modes
+ minify( $session, $mode, 'js', @files );
+ }
+
+ my $base_uri = $session->config->get('uploadsURL');
+ my $digest = get_digest( $session, 'js' );
+ my $asset_uri = "$base_uri/$MINIFIED/$ASSETS.js?digest=$digest";
+
+ return qq~~;
+}
+
+#----------------------------------------------------------------------------
+
+=head2 minify
+Minify js or css. Valid modes are 'debug' or 'min':
+In min mode:
+* js: minify into $ASSETS.js
+* css: minify into $ASSETS.css
+In debug mode:
+* js: concat into $ASSETS.js
+* css: invalid mode
+=cut
+
+sub minify {
+ my ( $session, $mode, $type, @files ) = @_;
+
+ # Only handle js and css
+ if ( $type ne 'js' && $type ne 'css' ) {
+ $session->log->error('Invalid type, skipping');
+ return;
+ }
+
+ # Valid modes for js: 'debug' and 'min'
+ if ( $type eq 'js' && $mode ne 'debug' && $mode ne 'min' ) {
+ $session->log->error("Invalid mode for $type: $mode");
+ return;
+ }
+
+ # Valid modes for css: 'min'
+ if ( $type eq 'css' && $mode ne 'min' ) {
+ $session->log->error("Invalid mode for $type: $mode");
+ return;
+ }
+
+ my $base_dir = $session->config->get('uploadsPath');
+ my $base_uri = $session->config->get('uploadsURL');
+ my $output_path = "$MINIFIED/$ASSETS";
+ my $asset_path = "$base_dir/$output_path.$type";
+
+ if ( !@files ) {
+ $session->log->error('Minify: No files to process, skipping');
+ return;
+ }
+
+ $session->log->debug("minify $mode mode for $type");
+
+ # Start with a clean slate
+ remove( $session, $asset_path );
+
+ my $concat = concat( $base_dir, @files );
+ my $digest = sha1_hex($concat);
+ update_digest( $session, $digest, $type );
+
+ if ( $mode eq 'debug' ) { # only applies to js
+ write_file( $asset_path, $concat );
+ }
+ else {
+ my $assets = File::Assets->new(
+ base => {
+ dir => $base_dir,
+ uri => $base_uri,
+ },
+ minify => 'xs',
+ );
+
+ # Built files go here (if we build at all)
+ $assets->set_output_path($output_path);
+
+ # Process the files..
+ foreach my $file (@files) {
+ $assets->include("$STATIC/$file");
+ }
+ $assets->export();
+ }
+
+ if ( $type eq 'css' ) {
+ rewrite_image_urls( $session, $asset_path, $digest );
+ }
+
+ $session->log->debug( "Minify: ${mode}'d " . @files . " assets to: $asset_path ($digest)" );
+
+ return;
+}
+
+#----------------------------------------------------------------------------
+
+=head2 rewrite_image_urls ( $session, $asset_path, $digest )
+Append digest query string to the end of all relative CSS image urls in the CSS file specified at $asset_path
+=cut
+
+sub rewrite_image_urls {
+ my ( $session, $asset_path, $digest ) = @_;
+ $session->log->debug('Adding digest to CSS image urls');
+ my $content = read_file($asset_path) or $session->log->warn("rewrite_image_urls unable to read $asset_path: $!");
+ $content =~ s{url\(([^/][^)]*)\)}{url($1?digest=$digest)}ig;
+ write_file( $asset_path, $content ) or $session->log->warn("rewrite_image_urls unable to write $asset_path: $!");
+ return;
+}
+
+#----------------------------------------------------------------------------
+
+=head2 remove ($session, $file)
+Unlink file if it exists
+=cut
+
+sub remove {
+ my ( $session, $file ) = @_;
+ if ( -e $file ) {
+ $session->log->debug("Removing file: $file");
+ unlink $file or $session->log->warn("Error removing $file: $!");
+ }
+ return;
+}
+
+#----------------------------------------------------------------------------
+
+=head2 update_digest ($session, $digest, $type)
+Update the cached digest in the db
+=cut
+
+sub update_digest {
+ my ( $session, $digest, $type ) = @_;
+ my $field_name = get_digest_field_name($type);
+ $session->db->write( 'delete from settings where name = ?', [$field_name] );
+ $session->db->write( 'insert into settings (name, value) values (?,?)', [ $field_name, $digest ] );
+ $session->log->debug("Minify: Set digest: $digest");
+ return;
+}
+
+#----------------------------------------------------------------------------
+
+=head2 concat ( $base_dir, @files)
+Concatenate the specified @files
+=cut
+
+sub concat {
+ my ( $base_dir, @files ) = @_;
+ my @output;
+ foreach my $file (@files) {
+ my $slurped = read_file("$base_dir/$STATIC/$file");
+ push @output, $slurped;
+ }
+ return join( "\n", @output ) . "\n";
+}
+
+1;