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;