File Coverage

blib/lib/CSS/Squish.pm
Criterion Covered Total %
statement 129 148 87.1
branch 36 48 75.0
condition 6 12 50.0
subroutine 16 18 88.8
pod 7 7 100.0
total 194 233 83.2


line stmt bran cond sub pod time code
1 8     8   15663 use 5.008;
  8         30  
  8         302  
2 8     8   44 use strict;
  8         19  
  8         324  
3 8     8   53 use warnings;
  8         14  
  8         551  
4              
5             package CSS::Squish;
6              
7             $CSS::Squish::VERSION = '0.10';
8              
9             # Setting this to true will enable lots of debug logging about what
10             # CSS::Squish is doing
11             $CSS::Squish::DEBUG = 0;
12              
13 8     8   41 use File::Spec;
  8         14  
  8         286  
14 8     8   47 use Scalar::Util qw(blessed);
  8         28  
  8         786  
15 8     8   18942 use URI;
  8         45142  
  8         238  
16 8     8   7079 use URI::file;
  8         57417  
  8         14164  
17              
18             =head1 NAME
19              
20             CSS::Squish - Compact many CSS files into one big file
21              
22             =head1 SYNOPSIS
23              
24             use CSS::Squish;
25             my $concatenated = CSS::Squish->concatenate(@files);
26              
27             my $squisher = CSS::Squish->new( roots => ['/root1', '/root2'] );
28             my $concatenated = $squisher->concatenate(@files);
29              
30             =head1 DESCRIPTION
31              
32             This module takes a list of CSS files and concatenates them, making sure
33             to honor any valid @import statements included in the files.
34              
35             The benefit of this is that you get to keep your CSS as individual files,
36             but can serve it to users in one big file, saving the overhead of possibly
37             dozens of HTTP requests.
38              
39             Following the CSS 2.1 spec, @import statements must be the first rules in
40             a CSS file. Media-specific @import statements will be honored by enclosing
41             the included file in an @media rule. This has the side effect of actually
42             I<improving> compatibility in Internet Explorer, which ignores
43             media-specific @import rules but understands @media rules.
44              
45             It is possible that future versions will include methods to compact
46             whitespace and other parts of the CSS itself, but this functionality
47             is not supported at the current time.
48              
49             =cut
50              
51             #
52             # This should be a decently close CSS 2.1 compliant parser for @import rules
53             #
54             # XXX TODO: This does NOT deal with comments at all at the moment. Which
55             # is sort of a problem.
56             #
57              
58             my @ROOTS = qw( );
59              
60             my @MEDIA_TYPES = qw(all aural braille embossed handheld print
61             projection screen tty tv);
62             my $MEDIA_TYPES = '(?:' . join('|', @MEDIA_TYPES) . ')';
63             my $MEDIA_LIST = qr/$MEDIA_TYPES(?:\s*,\s*$MEDIA_TYPES)*/;
64              
65             my $AT_IMPORT = qr/^\s* # leading whitespace
66             \@import\s+ # @import
67             (?:url\( # url(
68             \s* # optional whitespace
69             (?:"|')? # optional " or '
70             | # or
71             (?:"|')) # " or '
72             (.+?) # the filename
73             (?:(?:"|')? # optional " or '
74             \s* # optional whitespace
75             \) # )
76             | # or
77             (?:"|')) # " or '
78             (?:\s($MEDIA_LIST))? # the optional media list
79             \; # finishing semi-colon
80             \s*$ # trailing whitespace
81             /x;
82              
83             =head1 COMMON METHODS
84              
85             =head2 new( [roots=>[...]] )
86              
87             A constructor. For backward compatibility with versions prior to 0.06
88             you can still call everything as a class method, but should remember
89             that roots are shared between all callers in this case.
90              
91             if you're using persistent environment (like mod_perl) then it's very
92             recomended to use objects.
93              
94             =cut
95              
96             sub new {
97 0     0 1 0 my $proto = shift;
98 0   0     0 return bless {@_}, ref($proto) || $proto;
99             }
100              
101             =head2 concatenate( @files )
102              
103             Takes a list of files to concatenate and returns the results as one big scalar.
104              
105             =head2 concatenate_to( $dest, @files )
106              
107             Takes a filehandle to print to and a list of files to concatenate.
108             C<concatenate> uses this method with an C<open>ed scalar.
109              
110             =cut
111              
112             sub concatenate {
113 7     7 1 5274 my $self = shift;
114 7         38 my $string = '';
115            
116 7         34 $self->_debug("Opening scalar as file");
117            
118 7 50   6   268 open my $fh, '>', \$string or die "Can't open scalar as file! $!";
  6         64  
  6         13  
  6         52  
119 7         8784 $self->concatenate_to($fh, @_);
120              
121 7         28 $self->_debug("Closing scalar as file");
122 7         22 close $fh;
123              
124 7         48 return $string;
125             }
126              
127             sub concatenate_to {
128 7     7 1 16 my $self = shift;
129 7         13 my $dest = shift;
130              
131 7         42 $self->_debug("Looping over list of files: ", join(", ", @_), "\n");
132              
133 7         21 my %seen = ();
134 7         69 while ( my $file = shift @_ ) {
135              
136 7 50       45 next if $seen{ $file }{'all'}++;
137              
138 7         29 my $fh = $self->file_handle( $file );
139 7 50       25 unless ( defined $fh ) {
140 0         0 $self->_debug("Skipping '$file'...");
141 0         0 print $dest qq[/* WARNING: Unable to find/open file '$file' */\n];
142 0         0 next;
143             }
144 7         41 $self->_concatenate_to( $dest, $fh, $file, \%seen );
145             }
146             }
147              
148             sub _concatenate_to {
149 21     21   41 my $self = shift;
150 21         51 my $dest = shift;
151 21         38 my $fh = shift;
152 21         33 my $file = shift;
153 21   50     71 my $seen = shift || {};
154              
155 21         431 while ( my $line = <$fh> ) {
156 46 100       403 if ( $line =~ /$AT_IMPORT/o ) {
157 18         67 my $import = $1;
158 18         43 my $media = $2;
159              
160 18         103 $self->_debug("Processing import '$import'");
161              
162             # resolve URI against the current file and get the file path
163             # which is always relative to our root(s)
164 18         75 my $path = $self->resolve_uri( $import, $file );
165 18 100       78 unless ( defined $path ) {
166 1         5 $self->_debug("Skipping import because couldn't resolve URL");
167 1         5 print $dest $line;
168 1         6 next;
169             }
170              
171 17 100       117 if ( $seen->{ $path }{'all'} ) {
172 1         7 $self->_debug("Skipping import as it was included for all media types");
173 1         7 print $dest "/** Skipping: \n", $line, " */\n\n";
174 1         8 next;
175             }
176              
177 16 100       59 if ( $media ) {
178 8   50     122 my @list = sort map lc, split /\s*,\s*/, ($media||'');
179 8 50       52 if ( grep $_ eq 'all', @list ) {
180 0         0 @list = ();
181             }
182 8         31 $media = join ', ', @list;
183             }
184 16 50 100     143 if ( $seen->{ $path }{ $media || 'all' }++ ) {
185 0         0 $self->_debug("Skipping import as it's recursion");
186 0         0 print $dest "/** Skipping: \n", $line, " */\n\n";
187 0         0 next;
188             }
189              
190             # Look up the new file in root(s), so we can leave import
191             # if something is wrong
192 16         81 my $new_fh = $self->file_handle( $path );
193 16 100       55 unless ( defined $new_fh ) {
194 2         10 $self->_debug("Skipping import of '$import'");
195              
196 2         8 print $dest qq[/* WARNING: Unable to find import '$import' */\n];
197 2         6 print $dest $line;
198 2         13 next;
199             }
200              
201 14         92 print $dest "\n/**\n * From $file: $line */\n\n";
202              
203 14 100       43 if ( defined $media ) {
204 6         26 print $dest "\@media $media {\n";
205 6         64 $self->_concatenate_to($dest, $new_fh, $path, $seen);
206 6         15 print $dest "}\n";
207             }
208             else {
209 8         94 $self->_concatenate_to($dest, $new_fh, $path, $seen);
210             }
211              
212 14         173 print $dest "\n/** End of $import */\n\n";
213             }
214             else {
215 28         63 print $dest $line;
216 28 100       173 last if not $line =~ /^\s*$/;
217             }
218             }
219 21         68 $self->_debug("Printing the rest");
220 21         29 local $_;
221 21         243 print $dest $_ while <$fh>;
222 21         358 close $fh;
223             }
224              
225             =head1 RESOLVING METHODS
226              
227             The following methods help map URIs to files and find them on the disk.
228              
229             In common situation you control CSS and can adopt it to use imports with
230             relative URIs and most probably only have to set root(s).
231              
232             However, you can subclass these methods to parse css files before submitting,
233             implement advanced mapping of URIs to file system and other things.
234              
235             Mapping works in the following way. When you call concatenate method we get
236             content of file using file_handle method which as well lookup files in roots.
237             If roots are not defined then files are treated as absolute paths or relative
238             to the current directory. Using of absolute paths is not recommended as
239             unhide server dirrectory layout to clients in css comments and as well don't
240             allow to handle @import commands with absolute URIs. When files is found we
241             parse its content for @import commands. On each URI we call resolve_uri method
242             that convert absolute and relative URIs into file paths.
243              
244             Here is example of processing:
245              
246             roots: /www/overlay/, /www/shared/
247              
248             $squisher->concatenate('/css/main.css');
249            
250             ->file_handle('/css/main.css');
251             ->resolve_file('/css/main.css');
252             <- '/www/shared/css/main.css';
253             <- handle;
254              
255             content parsing
256             find '@import url(nav.css)'
257             -> resolve_uri('nav.css', '/css/main.css');
258             <- '/css/nav.css';
259             ... recursivly process file
260             find '@import url(/css/another.css)'
261             -> resolve_uri('/css/another.css', '/css/main.css');
262             <- '/css/another.css'
263             ...
264              
265             =head2 roots( @dirs )
266              
267             A getter/setter for paths to search when looking for files.
268              
269             The paths specified here are searched for files. This is useful if
270             your server has multiple document roots or document root doesn't match
271             the current dir.
272              
273             See also 'resolve_file' below.
274              
275             =cut
276              
277             sub roots {
278 24     24 1 846 my $self = shift;
279 24         43 my @res;
280 24 50       120 unless ( blessed $self ) {
281 24 100       80 @ROOTS = @_ if @_;
282 24         62 @res = @ROOTS;
283             } else {
284 0 0       0 $self->{'roots'} = [ grep defined, @_ ] if @_;
285 0         0 @res = @{ $self->{'roots'} };
  0         0  
286             }
287 24         225 $self->_debug("Roots are: ". join ", ", map "'$_'", @res);
288 24         69 return @res;
289             }
290              
291             =head2 file_handle( $file )
292              
293             Takes a path to a file, resolves (see resolve_file) it and returns a handle.
294              
295             Returns undef if file couldn't be resolved or it's impossible to open file.
296              
297             You can subclass it to filter content, process it with templating system or
298             generate it on the fly:
299              
300             package My::CSS::Squish;
301             use base qw(CSS::Squish);
302              
303             sub file_handle {
304             my $self = shift;
305             my $file = shift;
306            
307             my $content = $self->my_prepare_content($file);
308             return undef unless defined $content;
309              
310             open my $fh, "<", \$content or warn "Couldn't open handle: $!";
311             return $fh;
312             }
313              
314             B<Note> that the file is not resolved yet and is relative to the root(s), so
315             you have to resolve it yourself or call resolve_file method.
316              
317             =cut
318              
319             sub file_handle {
320 23     23 1 50 my $self = shift;
321 23         41 my $file = shift;
322              
323 23         75 my $path = $self->resolve_file( $file );
324 23 100       73 unless ( defined $path ) {
325 2         11 $self->_debug("Couldn't find '$file' in root(s)");
326 2         4 return undef;
327             }
328              
329 21         36 my $fh;
330 21 50       1022 unless ( open $fh, '<', $path ) {
331 0         0 $self->_debug("Skipping '$file' ($path) due to error: $!");
332 0         0 return undef;
333             }
334 21         76 return $fh;
335             }
336              
337             =head2 resolve_file( $file )
338              
339             Lookup file in the root(s) and returns first path it found or undef.
340              
341             When roots are not set just checks if file exists.
342              
343             =cut
344              
345             sub resolve_file {
346 23     23 1 38 my $self = shift;
347 23         42 my $file = shift;
348              
349 23         118 $self->_debug("Looking for '$file'");
350 23         85 my @roots = $self->roots;
351 23 100       86 unless ( @roots ) {
352 19 100       540 return undef unless -e $file;
353 17         62 return $file;
354             }
355              
356 4         9 foreach my $root ( @roots ) {
357 9         28 $self->_debug("Searching in '$root'");
358 9         72 my @spec = File::Spec->splitpath( $root, 1 );
359 9         66 my $path = File::Spec->catpath( @spec[0,1], $file );
360              
361 9 100       175 return $path if -e $path;
362             }
363 0         0 return undef;
364             }
365              
366             =head2 _resolve_file( $file, @roots )
367              
368             DEPRECATED. This private method is deprecated and do nothing useful except
369             maintaining backwards compatibility. If you were using it then most probably
370             to find files in roots before submitting them into concatenate method. Now,
371             it's not required and this method returns back file path without changes.
372              
373             =cut
374              
375             sub _resolve_file {
376 0     0   0 my ($self, $file, @roots) = @_;
377 0         0 require Carp;
378 0         0 Carp::carp("You called ->_resolve_file($file, ...). The method is deprecated!");
379 0         0 return $file;
380             }
381              
382             =head2 resolve_uri( $uri_string, $base_file )
383              
384             Takes an URI and base file path and transforms it into new
385             file path.
386              
387             =cut
388              
389             sub resolve_uri {
390 18     18 1 30 my $self = shift;
391 18         35 my $uri_str = shift;
392 18         31 my $base_file = shift;
393              
394 18         119 my $uri = URI->new( $uri_str, 'http' );
395              
396 18 100 66     25888 if ( defined $uri->scheme || defined $uri->authority ) {
397 1         197 $self->_debug("Skipping uri because it's external");
398 1         16 return undef;
399             }
400              
401 17         2021 my $strip_leading_slash = 0;
402 17 50       68 unless ( $base_file =~ m{^/} ) {
403 17         195 $base_file = '/'. $base_file;
404 17         96 $strip_leading_slash = 1;
405             }
406 17         119 my $base_uri = URI::file->new( $base_file );
407              
408 17         27132 my $path = $uri->abs( $base_uri )->path;
409 17 50       6254 $path =~ s{^/}{} if $strip_leading_slash;
410 17         166 return $path;
411             }
412              
413             sub _debug {
414 123     123   189 my $self = shift;
415 123 50       379 warn( ( caller(1) )[3], ": ", @_, "\n") if $CSS::Squish::DEBUG;
416             }
417              
418             =head1 BUGS AND SHORTCOMINGS
419              
420             At the current time, comments are not skipped. This means comments happening
421             before @import statements at the top of a file will cause the @import rules
422             to not be parsed. Make sure the @import rules are the very first thing in
423             the file (and only one per line). Processing of @import rules stops as soon
424             as the first line that doesn't match an @import rule is encountered.
425              
426             All other bugs should be reported via
427             L<http://rt.cpan.org/Public/Dist/Display.html?Name=CSS-Squish>
428             or L<bug-CSS-Squish@rt.cpan.org>.
429              
430             =head1 AUTHOR
431              
432             Thomas Sibley <trs@bestpractical.com>, Ruslan Zakirov <ruz@bestpractical.com>
433              
434             =head1 COPYRIGHT AND LICENSE
435              
436             Copyright (c) 2006.
437              
438             This library is free software; you can redistribute it and/or modify
439             it under the same terms as Perl itself, either Perl version 5.8.3 or,
440             at your option, any later version of Perl 5 you may have available.
441              
442             =cut
443              
444             1;
445