File Coverage

blib/lib/Mojolicious/Plugin/AssetPack/Pipe/Sass.pm
Criterion Covered Total %
statement 15 117 12.8
branch 0 52 0.0
condition 0 6 0.0
subroutine 5 13 38.4
pod 1 1 100.0
total 21 189 11.1


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::AssetPack::Pipe::Sass;
2 1     1   613 use Mojo::Base 'Mojolicious::Plugin::AssetPack::Pipe';
  1         2  
  1         15  
3              
4 1     1   95 use Mojolicious::Plugin::AssetPack::Util qw(checksum diag dumper load_module DEBUG);
  1         3  
  1         78  
5 1     1   7 use Mojo::File;
  1         2  
  1         47  
6 1     1   6 use Mojo::JSON qw(decode_json encode_json);
  1         3  
  1         75  
7 1     1   8 use Mojo::Util;
  1         39  
  1         3377  
8              
9             my $FORMAT_RE = qr{^s[ac]ss$};
10             my $IMPORT_RE = qr{ (?:^|[\n\r]+) ([^\@\r\n]*) (\@import \s+ (["']) (.*?) \3 \s* ;)}sx;
11             my $SOURCE_MAP_PLACEHOLDER = sprintf '__%s__', __PACKAGE__;
12              
13             $SOURCE_MAP_PLACEHOLDER =~ s!::!_!g;
14              
15             has functions => sub { +{} };
16             has generate_source_map => sub { shift->app->mode eq 'development' ? 1 : 0 };
17              
18             sub process {
19 0     0 1   my ($self, $assets) = @_;
20 0           my $store = $self->assetpack->store;
21 0           my %opts = (include_paths => [undef, @{$self->assetpack->store->paths}]);
  0            
22 0           my $file;
23              
24 0           for my $name (keys %{$self->functions}) {
  0            
25 0           my $cb = $self->functions->{$name};
26 0     0     $opts{sass_functions}{$name} = sub { $self->$cb(@_); };
  0            
27             }
28              
29 0 0         if ($self->generate_source_map) {
30 0           $opts{source_map_file} = $SOURCE_MAP_PLACEHOLDER;
31 0 0         $opts{source_map_file_urls} = $self->app->mode eq 'development' ? 1 : 0;
32             }
33              
34             return $assets->each(sub {
35 0     0     my ($asset, $index) = @_;
36              
37 0 0         return if $asset->format !~ $FORMAT_RE;
38 0           my ($attrs, $content) = ($asset->TO_JSON, $asset->content);
39 0           local $self->{checksum_for_file} = {};
40 0           local $opts{include_paths}[0] = _include_path($asset);
41 0           $attrs->{minified} = $self->assetpack->minify;
42 0 0         $attrs->{key} = sprintf 'sass%s', $attrs->{minified} ? '-min' : '';
43 0           $attrs->{format} = 'css';
44 0           $attrs->{checksum} = $self->_checksum(\$content, $asset, $opts{include_paths});
45              
46 0 0         return $asset->content($file)->FROM_JSON($attrs) if $file = $store->load($attrs);
47 0 0         return if $asset->isa('Mojolicious::Plugin::AssetPack::Asset::Null');
48 0 0         $opts{include_paths}[0] = $asset->path ? $asset->path->dirname : undef;
49 0           $opts{include_paths} = [grep {$_} @{$opts{include_paths}}];
  0            
  0            
50 0           diag 'Process "%s" with checksum %s.', $asset->url, $attrs->{checksum} if DEBUG;
51              
52 0 0 0       if ($self->{has_module} //= eval { load_module 'CSS::Sass'; 1 }) {
  0            
  0            
53 0           $opts{output_style} = _output_style($attrs->{minified});
54 0 0         $content = CSS::Sass::sass2scss($content) if $asset->format eq 'sass';
55 0           my ($css, $err, $stats) = CSS::Sass::sass_compile($content, %opts);
56 0 0         if ($err) {
57 0           die sprintf '[Pipe::Sass] Could not compile "%s" with opts=%s: %s', $asset->url, dumper(\%opts), $err;
58             }
59 0           $css = Mojo::Util::encode('UTF-8', $css);
60 0 0         $self->_add_source_map_asset($asset, \$css, $stats) if $stats->{source_map_string};
61 0           $asset->content($store->save(\$css, $attrs))->FROM_JSON($attrs);
62             }
63             else {
64 0           my @args = (qw(sass -s --trace), map { ('-I', $_) } @{$opts{include_paths}});
  0            
  0            
65 0 0         push @args, '--scss' if $asset->format eq 'scss';
66 0 0         push @args, qw(-t compressed) if $attrs->{minified};
67 0           $self->run(\@args, \$content, \my $css, \my $err);
68 0 0         my $exit = $? > 0 ? $? >> 8 : $?;
69 0 0         if ($exit) {
70 0           die sprintf '[Pipe::Sass] Could not compile "%s" with opts=%s: %s', $asset->url, dumper(\%opts), $err;
71             }
72 0           $asset->content($store->save(\$css, $attrs))->FROM_JSON($attrs);
73             }
74 0           });
75             }
76              
77             sub _add_source_map_asset {
78 0     0     my ($self, $asset, $css, $stats) = @_;
79 0           my $data = decode_json $stats->{source_map_string};
80 0           my $source_map = Mojolicious::Plugin::AssetPack::Asset->new(url => sprintf('%s.css.map', $asset->name));
81              
82             # override "stdin" with real file
83 0 0         $data->{file} = sprintf 'file://%s', $asset->path if $asset->path;
84 0           $data->{sources}[0] = $data->{file};
85 0           $source_map->content(encode_json $data);
86              
87 0           my $relative = join '/', '..', $source_map->checksum, $source_map->url;
88 0           $$css =~ s!$SOURCE_MAP_PLACEHOLDER!$relative!;
89              
90             # TODO
91 0           $self->assetpack->{by_checksum}{$source_map->checksum} = $source_map;
92 0           $self->assetpack->{by_topic}{$source_map->url} = Mojo::Collection->new($source_map);
93             }
94              
95             sub _checksum {
96 0     0     my ($self, $ref, $asset, $paths) = @_;
97 0           my $ext = $asset->format;
98 0           my $store = $self->assetpack->store;
99 0           my @c = (checksum $$ref);
100              
101             SEARCH:
102 0           while ($$ref =~ /$IMPORT_RE/gs) {
103 0           my $pre = $1;
104 0           my $rel_path = $4;
105 0           my $mlen = length $2;
106 0           my @rel = split '/', $rel_path;
107 0           my $name = pop @rel;
108 0           my $start = pos($$ref) - $mlen;
109 0           my $dynamic = $rel_path =~ m!http://local/!;
110 0           my @basename = ("_$name", $name);
111              
112 0 0         next if $pre =~ m{^\s*//};
113              
114             # Follow sass rules for skipping,
115             # ...with exception for special assetpack handling for dynamic sass include
116 0 0         next if $rel_path =~ /\.css$/;
117 0 0 0       next if $rel_path =~ m!^https?://! and !$dynamic;
118              
119 0 0         unshift @basename, "_$name.$ext", "$name.$ext" unless $name =~ /\.$ext$/;
120 0 0         my $imported = $store->asset([map { join '/', @rel, $_ } @basename], $paths)
  0            
121             or die qq([Pipe::Sass] Could not find "$rel_path" file in @$paths);
122              
123 0 0         if ($imported->path) {
124 0           diag '@import "%s" (%s)', $rel_path, $imported->path if DEBUG >= 2;
125 0           local $paths->[0] = _include_path($imported);
126 0           push @c, $self->_checksum(\$imported->content, $imported, $paths);
127             }
128             else {
129 0           diag '@import "%s" (memory)', $rel_path if DEBUG >= 2;
130 0           pos($$ref) = $start;
131 0           substr $$ref, $start, $mlen, $imported->content; # replace "@import ..." with content of asset
132 0           push @c, $imported->checksum;
133             }
134             }
135              
136 0           return checksum join ':', @c;
137             }
138              
139             sub _include_path {
140 0     0     my $asset = shift;
141 0 0         return $asset->url if $asset->url =~ m!^https?://!;
142 0 0         return $asset->path->dirname if $asset->path;
143 0           return '';
144             }
145              
146             sub _install_sass {
147 0     0     my $self = shift;
148 0           $self->run([qw(ruby -rubygems -e), 'puts Gem.user_dir'], undef, \my $base);
149 0           chomp $base;
150 0           my $path = Mojo::File->new($base, qw(bin sass));
151 0 0         return $path if -e $path;
152 0           $self->app->log->warn('Installing sass... Please wait. (gem install --user-install sass)');
153 0           $self->run([qw(gem install --user-install sass)]);
154 0           return $path;
155             }
156              
157             sub _output_style {
158 0 0   0     return $_[0] ? CSS::Sass::SASS_STYLE_COMPRESSED() : CSS::Sass::SASS_STYLE_NESTED();
159             }
160              
161             1;
162              
163             =encoding utf8
164              
165             =head1 NAME
166              
167             Mojolicious::Plugin::AssetPack::Pipe::Sass - Process sass and scss files
168              
169             =head1 SYNOPSIS
170              
171             =head2 Application
172              
173             plugin AssetPack => {pipes => [qw(Sass Css Combine)]};
174              
175             $self->pipe("Sass")->functions({
176             q[image-url($arg)] => sub {
177             my ($pipe, $arg) = @_;
178             return sprintf "url(/assets/%s)", $_[1];
179             }
180             });
181              
182             =head2 Sass file
183              
184             The sass file below shows how to use the custom "image-url" function:
185              
186             body {
187             background: #fff image-url('img.png') top left;
188             }
189              
190             =head1 DESCRIPTION
191              
192             L will process sass and scss files.
193              
194             This module require either the optional module L or the C
195             program to be installed. C will be automatically installed using
196             L unless already available.
197              
198             =head1 ATTRIBUTES
199              
200             =head2 functions
201              
202             $hash_ref = $self->functions;
203              
204             Used to define custom SASS functions. Note that the functions will be called
205             with C<$self> as the first argument, followed by any arguments from the SASS
206             function. This invocation is EXPERIMENTAL, but will hopefully not change.
207              
208             This attribute requires L to work. It will not get passed on to
209             the C executable.
210              
211             See L for example.
212              
213             =head2 generate_source_map
214              
215             $bool = $self->generate_source_map;
216             $self = $self->generate_source_map(1);
217              
218             This pipe will generate source maps if true. Default is "1" if
219             L is "development".
220              
221             See also L and
222             L for more
223             information about the usefulness.
224              
225             =head1 METHODS
226              
227             =head2 process
228              
229             See L.
230              
231             =head1 SEE ALSO
232              
233             L.
234              
235             =cut