478 lines
14 KiB
Perl
478 lines
14 KiB
Perl
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:
|
|
<Location /uploads/static/frozen>
|
|
ExpiresActive On
|
|
ExpiresDefault "access plus 10 years"
|
|
</Location>
|
|
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:
|
|
<Location /uploads/minified>
|
|
ExpiresActive On
|
|
ExpiresDefault "access plus 10 years"
|
|
</Location>
|
|
|
|
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<link rel="stylesheet" href="$original_file_uri" type="text/css">~;
|
|
}
|
|
}
|
|
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~<link rel="stylesheet" href="$asset_uri" type="text/css">~;
|
|
}
|
|
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~<script type="text/javascript" src="$asset_uri"></script>~;
|
|
}
|
|
|
|
#----------------------------------------------------------------------------
|
|
|
|
=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;
|