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;