package Mojolicious::Plugin::AssetPack;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::Util qw(deprecated trim xml_escape);
use Mojolicious::Plugin::AssetPack::Asset::Null;
use Mojolicious::Plugin::AssetPack::Store;
use Mojolicious::Plugin::AssetPack::Util qw(diag has_ro load_module DEBUG);
our $VERSION = '2.10';
has minify => sub { shift->_app->mode eq 'development' ? 0 : 1 };
has route => sub {
shift->_app->routes->any([qw(HEAD GET)] => '/asset/:checksum/*name')->name('assetpack')->to(cb => \&_serve);
};
has store => sub {
my $self = shift;
Mojolicious::Plugin::AssetPack::Store->new(
classes => [@{$self->_app->static->classes}],
paths => [$self->_app->home->rel_file('assets')],
ua => $self->ua,
);
};
has_ro ua => sub { Mojo::UserAgent->new->max_redirects(3) };
sub pipe {
my ($self, $needle) = @_;
return +(grep { $_ =~ /::$needle\b/ } @{$self->{pipes}})[0];
}
sub process {
my ($self, $topic, @input) = @_;
$self->route unless $self->{route_added}++;
return $self->_process_from_def($topic) unless @input;
# TODO: The idea with blessed($_) is that maybe the user can pass inn
# Mojolicious::Plugin::AssetPack::Sprites object, with images to generate
# CSS from?
my $assets = Mojo::Collection->new;
for my $url (@input) {
my $asset = Scalar::Util::blessed($url) ? $url : $self->store->asset($url);
die qq(Could not find input asset "$url".) unless Scalar::Util::blessed($asset);
push @$assets, $asset;
}
return $self->tap(sub { $_->{input}{$topic} = $assets }) if $self->{lazy};
return $self->_process($topic => $assets);
}
sub processed {
my ($self, $topic) = @_;
$self->_process($topic => $self->{input}{$topic}) unless $self->{by_topic}{$topic}; # Ensure asset is processed
return $self->{by_topic}{$topic};
}
sub register {
my ($self, $app, $config) = @_;
my $helper = $config->{helper} || 'asset';
if ($app->renderer->helpers->{$helper}) {
return $app->log->debug("AssetPack: Helper $helper() is already registered.");
}
$self->{input} = {};
$self->{lazy} ||= $ENV{MOJO_ASSETPACK_LAZY} // $config->{lazy} || 0;
$app->defaults('assetpack.helper' => $helper);
$self->ua->server->app($app);
Scalar::Util::weaken($self->ua->server->{app});
if (my $proxy = $config->{proxy} // {}) {
local $ENV{NO_PROXY} = $proxy->{no_proxy} || join ',', grep {$_} $ENV{NO_PROXY}, $ENV{no_proxy}, '127.0.0.1',
'::1', 'localhost';
diag 'Detecting proxy settings. (NO_PROXY=%s)', $ENV{NO_PROXY} if DEBUG;
$self->ua->proxy->detect;
}
$self->_pipes($config->{pipes} || []);
$app->helper($helper => sub { @_ == 1 ? $self : $self->_render_tags(@_) });
}
sub tag_for {
my $self = shift;
deprecated 'tag_for() is DEPRECATED in favor of Mojolicious::Plugin::AssetPack::Asset::tag_for()';
return $self->{tag_for} unless @_;
$self->{tag_for} = shift;
return $self;
}
sub _app { shift->ua->server->app }
sub _correct_mode {
my ($self, $args) = @_;
while ($args =~ /\[(\w+)([!=]+)([^\]]+)/g) {
my $v = $1 eq 'minify' ? $self->minify : $self->_app->$1;
diag "Checking $1: $v $2 $3" if DEBUG == 2;
return 0 if $2 eq '!=' and $v eq $3;
return 0 if $2 ne '!=' and $v ne $3; # default to testing equality
}
return 1;
}
sub _pipes {
my ($self, $names) = @_;
$self->{pipes} = [
map {
my $class = load_module /::/ ? $_ : "Mojolicious::Plugin::AssetPack::Pipe::$_";
diag 'Loading pipe "%s".', $class if DEBUG;
my $pipe = $class->new(assetpack => $self);
Scalar::Util::weaken($pipe->{assetpack});
$pipe;
} @$names
];
}
sub _process {
my ($self, $topic, $input) = @_;
my $assets = Mojo::Collection->new(@$input); # Do not mess up input
local $Mojolicious::Plugin::AssetPack::Util::TOPIC = $topic; # Used by diag()
for my $asset (@$assets) {
if (my $prev = $self->{by_topic}{$topic}) {
delete $asset->{$_} for qw(checksum format);
$asset->content($self->store->asset($asset->url));
}
$asset->checksum;
}
for my $method (qw(before_process process after_process)) {
for my $pipe (@{$self->{pipes}}) {
next unless $pipe->can($method);
local $pipe->{topic} = $topic;
diag '%s->%s("%s")', ref $pipe, $method, $topic if DEBUG;
$pipe->$method($assets);
}
}
if (my $tag_for = $self->{tag_for}) {
$_->{tag_for} or $_->{tag_for} = $tag_for for @$assets;
}
my @checksum = map { $_->checksum } @$assets;
$self->_app->log->debug(qq(Processed asset "$topic". [@checksum])) if DEBUG;
$self->{by_checksum}{$_->checksum} = $_ for @$assets;
$self->{by_topic}{$topic} = $assets;
$self->store->persist;
$self;
}
sub _process_from_def {
my $self = shift;
my $file = shift || 'assetpack.def';
my $asset = $self->store->file($file);
my $topic = '';
my %process;
die qq(Unable to load "$file".) unless $asset;
diag qq(Loading asset definitions from "$file".) if DEBUG;
for (split /\r?\n/, $asset->slurp) {
s/\s*\#.*//;
if (/^\<(\S*)\s+(\S+)\s*(.*)/) {
my ($class, $url, $args) = ($1, $2, $3);
next unless $self->_correct_mode($args);
my $asset = $self->store->asset($url);
die qq(Could not find input asset "$url".) unless Scalar::Util::blessed($asset);
bless $asset, 'Mojolicious::Plugin::AssetPack::Asset::Null' if $class eq '<';
push @{$process{$topic}}, $asset;
}
elsif (/^\!\s*(.+)/) { $topic = trim $1; }
}
$self->process($_ => @{$process{$_}}) for keys %process;
$self;
}
sub _render_tags {
my ($self, $c, $topic, @attrs) = @_;
my $route = $self->route;
$self->_process($topic => $self->{input}{$topic}) if $self->{lazy};
my $assets = $self->{by_topic}{$topic} ||= $self->_static_asset($topic);
my %args = (base_url => $route->pattern->defaults->{base_url} || '', topic => $topic);
$args{base_url} =~ s!/+$!!;
return Mojo::ByteStream->new(
join "\n",
map { $_->tag_for->($_, $c, \%args, @attrs) }
grep { !$_->isa('Mojolicious::Plugin::AssetPack::Asset::Null') } @$assets
);
}
sub _serve {
my $c = shift;
my $helper = $c->stash('assetpack.helper');
my $self = $c->$helper;
my $checksum = $c->stash('checksum');
if (my $asset = $self->{by_checksum}{$checksum}) {
$self->store->serve_asset($c, $asset);
return $c->rendered;
}
my $topic = $c->stash('name');
if (my $assets = $self->{by_topic}{$topic}) {
return $self->store->serve_fallback_for_assets($c, $topic, $assets);
}
$c->render(text => sprintf("// No such asset '%s'\n", xml_escape $topic), status => 404);
}
sub _static_asset {
my ($self, $topic) = @_;
my $asset = $self->store->asset($topic) or die qq(No assets registered by topic "$topic".);
my $assets = Mojo::Collection->new($asset);
$self->{by_checksum}{$_->checksum} = $_ for @$assets;
return $assets;
}
1;
=encoding utf8
=head1 NAME
Mojolicious::Plugin::AssetPack - Compress and convert css, less, sass, javascript and coffeescript files
=head1 DESCRIPTION
L<Mojolicious::Plugin::AssetPack> has a very limited feature set, especially
when it comes to processing JavaScript. It is recommended that you switch to
L<Mojolicious::Plugin::Webpack> if you want to write modern JavaScript code.
=head2 Existing user?
It is I<very> simple to migrate from L<Mojolicious::Plugin::AssetPack> to
L<Mojolicious::Plugin::Webpack>. Just check out the one line change in
L<Mojolicious::Plugin::Webpack/MIGRATING-FROM-ASSETPACK>.
=head2 Don't want to switch?
Your existing code will probably continue to work for a long time, but it will
get more and more difficult to write I<new> working JavaScript with
L<Mojolicious::Plugin::AssetPack> as time goes by.
=head2 New user?
Look no further. Just jump over to L<Mojolicious::Plugin::Webpack>.
=head1 HELPERS
=head2 asset
$self = $app->asset;
$self = $c->asset;
$bytestream = $c->asset($topic, @args);
$bytestream = $c->asset("app.css", media => "print");
C<asset()> is the main entry point to this plugin. It can either be used to
access the L<Mojolicious::Plugin::AssetPack> instance or as a tag helper.
The helper name "asset" can be customized by specifying "helper" when
L<registering|/register> the plugin.
=head1 ATTRIBUTES
=head2 minify
$bool = $self->minify;
$self = $self->minify($bool);
Set this to true to combine and minify the assets. Defaults to false if
L<Mojolicious/mode> is "development" and true otherwise.
=head2 route
$route = $self->route;
$self = $self->route($route);
A L<Mojolicious::Routes::Route> object used to serve assets. The default route
responds to HEAD and GET requests and calls
L<serve_asset()|Mojolicious::Plugin::AssetPack::Store/serve_asset> on L</store>
to serve the asset.
The default route will be built and added to the L<application|Mojolicious>
when L</process> is called the first time.
=head2 store
$obj = $self->store;
$self = $self->store(Mojolicious::Plugin::AssetPack::Store->new);
Holds a L<Mojolicious::Plugin::AssetPack::Store> object used to locate, store
and serve assets.
=head2 tag_for
Deprecated. Use L<Mojolicious::Plugin::AssetPack::Asset/renderer> instead.
=head2 ua
$ua = $self->ua;
Holds a L<Mojo::UserAgent> which can be used to fetch assets either from local
application or from remote web servers.
=head1 METHODS
=head2 pipe
$obj = $self->pipe($name);
$obj = $self->pipe("Css");
Will return a registered pipe by C<$name> or C<undef> if none could be found.
=head2 process
$self = $self->process($topic => @assets);
$self = $self->process($definition_file);
Used to process assets. A C<$definition_file> can be used to define C<$topic>
and C<@assets> in a separate file.
C<$definition_file> defaults to "assetpack.def".
=head2 processed
$collection = $self->processed($topic);
Can be used to retrieve a L<Mojo::Collection> object, with zero or more
L<Mojolicious::Plugin::AssetPack::Asset> objects.
=head2 register
$self->register($app, \%config);
Used to register the plugin in the application. C<%config> can contain:
=over 2
=item * helper
Name of the helper to add to the application. Default is "asset".
=item * pipes
This argument is mandatory and need to contain a complete list of pipes that is
needed. Example:
$app->plugin(AssetPack => {pipes => [qw(Sass Css Combine)]);
=item * proxy
A hash of proxy settings. Set this to C<0> to disable proxy detection.
Currently only "no_proxy" is supported, which will set which requests that
should bypass the proxy (if any proxy is detected). Default is to bypass all
requests to localhost.
See L<Mojo::UserAgent::Proxy/detect> for more information.
=back
=head1 SEE ALSO
L<Mojolicious::Plugin::Webpack>.
=head1 COPYRIGHT AND LICENSE
Copyright (C) 2020, Jan Henning Thorsen
This program is free software, you can redistribute it and/or modify it under
the terms of the Artistic License version 2.0.
=head1 AUTHOR
Jan Henning Thorsen - C<jhthorsen@cpan.org>
Alexander Rymasheusky
Mark Grimes - C<mgrimes@cpan.org>
Per Edin - C<info@peredin.com>
Viktor Turskyi
=cut