File Coverage

lib/Templer/Site.pm
Criterion Covered Total %
statement 191 205 93.1
branch 53 90 58.8
condition 16 36 44.4
subroutine 21 21 100.0
pod 10 10 100.0
total 291 362 80.3


line stmt bran cond sub pod time code
1              
2             =head1 NAME
3            
4             Templer::Site - An interface to a templer site.
5            
6             =cut
7              
8             =head1 SYNOPSIS
9            
10             use strict;
11             use warnings;
12            
13             use Templer::Site;
14            
15             # Create the helper.
16             my $site = Templer::Site->new( suffix => ".skx" );
17            
18             # Get the pages/assets.
19             my @pages = $site->pages();
20             my @assets = $site->assets();
21            
22             =cut
23              
24             =head1 DESCRIPTION
25            
26             This class encapsulates a site. A site is comprised of "pages" and "assets".
27            
28             =over 8
29            
30             =item Pages
31            
32             Pages are things which are template expanded. These are represented
33             by instances of the C<Templer::Site::Page> class.
34            
35             =item Assets
36            
37             Assets are files that are merely copied from the input directory to
38             the output path. If we're running in "in-place" mode then they are
39             ignored.
40            
41             Assets are represented by instances of the C<Templer::Site::Assets> class.
42            
43             =back
44            
45             This class contains helpers for finding and returning arrays of
46             both such objects, and the code necessary to work with them and build
47             a site.
48            
49             =cut
50              
51             =head1 LICENSE
52            
53             This module is free software; you can redistribute it and/or modify it
54             under the terms of either:
55            
56             a) the GNU General Public License as published by the Free Software
57             Foundation; either version 2, or (at your option) any later version,
58             or
59            
60             b) the Perl "Artistic License".
61            
62             =cut
63              
64             =head1 AUTHOR
65            
66             Steve Kemp <steve@steve.org.uk>
67            
68             =cut
69              
70             =head1 COPYRIGHT AND LICENSE
71            
72             Copyright (C) 2012-2015 Steve Kemp <steve@steve.org.uk>.
73            
74             This library is free software. You can modify and or distribute it under
75             the same terms as Perl itself.
76            
77             =cut
78              
79             =head1 METHODS
80            
81             =cut
82              
83              
84 11     11   255545 use strict;
  11         13  
  11         245  
85 11     11   31 use warnings;
  11         12  
  11         307  
86              
87              
88              
89             package Templer::Site;
90              
91              
92 11     11   4763 use Data::Dumper;
  11         45905  
  11         474  
93 11     11   48 use File::Find;
  11         13  
  11         463  
94 11     11   34 use File::Path qw! mkpath !;
  11         12  
  11         368  
95 11     11   9040 use HTML::Template;
  11         98352  
  11         389  
96 11     11   3165 use Templer::Site::Page;
  11         15  
  11         293  
97 11     11   2535 use Templer::Site::Asset;
  11         16  
  11         19453  
98              
99              
100              
101             =head2 new
102            
103             Constructor, this should be given a hash of arguments for example:
104            
105             =over 8
106            
107             =item input
108            
109             The input directory to process.
110            
111             =item output
112            
113             The output directory to write to.
114            
115             =item suffix
116            
117             The suffixe that will discover "Pages", for example '.skx', or '.tmplr'.
118            
119             =cut
120              
121             =back
122            
123             =cut
124              
125             sub new
126             {
127 11     11 1 24690     my ( $proto, %supplied ) = (@_);
128 11   33     73     my $class = ref($proto) || $proto;
129              
130 11         18     my $self = {};
131              
132             #
133             # Allow user supplied values to override our defaults
134             #
135 11         37     foreach my $key ( keys %supplied )
136                 {
137 33         49         $self->{ lc $key } = $supplied{ $key };
138                 }
139              
140 11         20     bless( $self, $class );
141 11         30     return $self;
142             }
143              
144              
145              
146             =head2 init
147            
148             Ensure that the input directory exists.
149            
150             Create the output directory if we're not running in-place.
151            
152             Create array of destination files.
153            
154             =cut
155              
156             sub init
157             {
158 6     6 1 1556     my ($self) = (@_);
159              
160             #
161             # Ensure we have an input directory.
162             #
163 6         22     my $input = $self->{ 'input' };
164 6 50       80     if ( !-d $input )
165                 {
166 0         0         print "The input directory doesn't exist: $input\n";
167 0         0         exit;
168                 }
169              
170             #
171             # Ensure input directory contains a unique trailing /
172             #
173 6         14     $self->{ 'input' } .= "/";
174 6         25     $self->{ 'input' } =~ s{/+$}{/};
175              
176             #
177             # Ensure output directory contains a unique trailing /
178             #
179 6         20     $self->{ 'output' } .= "/";
180 6         18     $self->{ 'output' } =~ s{/+$}{/};
181              
182             #
183             # Create the output directory if missing, unless we're in-place
184             #
185 6         10     my $output = $self->{ 'output' };
186 6         9     my $inplace = $self->{ 'in-place' };
187              
188 6 100 66     483     File::Path::mkpath( $output, { verbose => 0, mode => oct(755) } )
189                   if ( !-d $output && ( !$inplace ) );
190              
191             #
192             # We will store the list of all destination files
193             #
194 6         26     $self->set( "output-files", [] );
195             }
196              
197              
198             =head2 pages
199            
200             A site comprises of a collection of pages and a collection of static resources
201             which aren't touched/modified - these are "assets".
202            
203             Return a C<Templer::Site::Page> object for each page we've found.
204            
205             B<NOTE> We don't process pages with a "." prefix, i.e. dotfiles.
206            
207             =cut
208              
209             sub pages
210             {
211 2     2 1 6     my ( $self, %args ) = (@_);
212              
213                 my $dir =
214 2   0     8       $args{ 'directory' } || $self->{ 'directory' } || $self->{ 'input' };
215 2   33     8     my $suffix = $args{ 'suffix' } || $self->{ 'suffix' };
216              
217                 return (
218 2         11              $self->_findFiles( must_match => $suffix . "\$",
219                                             object => "Templer::Site::Page",
220                                             directory => $dir,
221                                             hide_dotfiles => 1,
222                                           ) );
223             }
224              
225              
226             =head2 assets
227            
228             A site comprises of a collection of pages and a collection of static resources
229             which aren't touched/modified - these are "assets".
230            
231             Return a C<Templer::Site::Asset> object for each asset we find.
232            
233             B<NOTE> We include files which have a "." prefix here - to correctly
234             copy files such as ".htpasswd", ".htaccess", etc.
235            
236             =cut
237              
238             sub assets
239             {
240 2     2 1 5     my ( $self, %args ) = (@_);
241              
242                 my $dir =
243 2   0     6       $args{ 'directory' } || $self->{ 'directory' } || $self->{ 'input' };
244 2   33     8     my $suffix = $args{ 'suffix' } || $self->{ 'suffix' };
245              
246                 return (
247 2         8              $self->_findFiles( must_not_match => $suffix . "\$",
248                                             object => "Templer::Site::Asset",
249                                             directory => $dir,
250                                             hide_dotfiles => 0,
251                                           ) );
252              
253             }
254              
255              
256             =head2 _findFiles
257            
258             Internal method to find files beneath the given directory and return a new object
259             for each one.
260            
261             We assume that the object constructor receives a hash as its sole
262             argument with the key "file" containing the file path.
263            
264             =cut
265              
266             sub _findFiles
267             {
268 5     5   23     my ( $self, %args ) = (@_);
269              
270             #
271             # Remove the trailing "/" on the end of the directory to search.
272             #
273 5         34     $args{ 'directory' } =~ s/\/$//g;
274              
275             #
276             # Should we hide dotfiles?
277             #
278 5         9     my $dotfiles = $args{ 'hide_dotfiles' };
279              
280              
281             #
282             # Files we've found. Ignoring the suffix just now.
283             #
284 5         6     my %files;
285              
286                 File::Find::find( {
287                        wanted => sub {
288 26     26   26                my $name = $File::Find::name;
289 26 100 100     576                $files{ $name } += 1 unless ( $dotfiles && ( $name =~ /\/\./ ) );
290                        },
291                        follow => 0,
292                        no_chdir => 1
293                     },
294 5         499         $args{ 'directory' } );
295              
296             #
297             # Remove the input
298             #
299 5         29     delete $files{ $args{ 'directory' } };
300              
301             #
302             # OK now we need to find the matches.
303             #
304 5         6     my @matches;
305              
306             #
307             # The class-object we're going to construct.
308             #
309 5         10     my $class = $args{ 'object' };
310              
311 5 100       16     if ( $args{ 'must_match' } )
    100          
312                 {
313 2         10         foreach my $file ( sort keys %files )
314                     {
315 3 50       36             next if ( -d $file );
316 3 100       81             next unless ( $file =~ /$args{'must_match'}/ );
317 2         15             push( @matches, $class->new( file => $file ) );
318                     }
319                 }
320                 elsif ( $args{ 'must_not_match' } )
321                 {
322 2         14         foreach my $file ( sort keys %files )
323                     {
324 12 100       42             next if ( $file =~ /$args{'must_not_match'}/ );
325 10         24             push( @matches, $class->new( file => $file ) );
326                     }
327                 }
328                 else
329                 {
330 1         4         @matches = map {$class->new( file => $_ )} keys %files;
  5         21  
331                 }
332              
333             #
334             # Every pages depend on the global configuration file
335             #
336 5 100       20     if ( $args{ 'object' } eq "Templer::Site::Page" )
337                 {
338 2         6         foreach my $page (@matches)
339                     {
340 2         10             $page->add_dependency( $self->{ 'config' } );
341                     }
342                 }
343              
344 5         19     @matches;
345             }
346              
347              
348              
349             =head2 build
350            
351             Build the site.
352            
353             This is the method which does all the page-expansion, site-generation, etc.
354            
355             The return value is the count of pages built.
356            
357             =cut
358              
359             sub build
360             {
361 2     2 1 1785     my ($self) = (@_);
362              
363             #
364             # If we have a plugin directory then load the plugins beneath it.
365             #
366             # NOTE: The bundled/built-in plugins will always be available.
367             #
368 2         16     my $PLUGINS = Templer::Plugin::Factory->new();
369 2 50       38     if ( -d $self->{ 'plugin-path' } )
370                 {
371                     print "Loading plugins from : $self->{ 'plugin-path' }\n"
372 0 0       0           if ( $self->{ 'verbose' } );
373              
374 0         0         $PLUGINS->load_plugins( $self->{ 'plugin-path' } );
375                 }
376              
377             #
378             # Initialize all plugins.
379             #
380 2         7     $PLUGINS->init($self);
381              
382             #
383             # Setup an array of include-paths.
384             #
385 2         2     my @INCLUDES;
386 2         7     foreach my $path ( split( /:/, $self->{ 'include-path' } ) )
387                 {
388 2 50       26         push( @INCLUDES, $path ) if ( -d $path );
389                 }
390 2         7     $self->set( "include-path", \@INCLUDES );
391              
392              
393             #
394             # Find all the pages we'll process.
395             #
396             # (Assets are copied later.)
397             #
398 2         7     my @pages = $self->pages( directory => $self->{ 'input' } );
399              
400              
401             #
402             # A count of the pages we've rebuilt.
403             #
404 2         5     my $rebuilt = 0;
405              
406              
407             #
408             # For each page we've found.
409             #
410 2         4     foreach my $page (@pages)
411                 {
412              
413             #
414             # The path of the page, on-disk.
415             #
416 2         8         my $src = $page->source();
417 2 50       6         print "\nProcessing page: $src\n" if ( $self->{ 'verbose' } );
418              
419              
420             #
421             # Convert the input path to a suitable output path.
422             #
423 2         3         my $dst = $src;
424              
425             #
426             # The page might have its own idea of where it wants to
427             # go - so set that if it is set.
428             #
429 2 50       5         if ( $page->field("output") )
430                     {
431 0         0             $dst = $self->{ 'output' } . $page->field("output");
432                     }
433                     else
434                     {
435                         $dst =~ s/^$self->{'input'}/$self->{'output'}/g
436 2 50       35               unless ( $self->{ 'in-place' } );
437              
438 2         17             $dst =~ s/$self->{'suffix'}/.html/g;
439                     }
440              
441             #
442             # Store the destination file path
443             #
444 2         2         push( @{ $self->{ 'output-files' } }, $dst );
  2         6  
445              
446             #
447             # Show the transformation.
448             #
449 2 50       8         print "File: $src\n" if ( $self->{ 'verbose' } );
450 2 50       4         print "Dest: $dst\n" if ( $self->{ 'verbose' } );
451              
452              
453             #
454             # The template to expand the content into will come from the page, or
455             # the global configuration object.
456             #
457                     my $template = $page->layout() ||
458 2   33     7           $self->{ 'layout' };
459                     print "Layout file is: $self->{'layout-path'}/$template\n"
460 2 50       7           if ( $self->{ 'verbose' } );
461              
462             #
463             # Ensure the template exists.
464             #
465 2 50       29         if ( !-e $self->{ 'layout-path' } . "/" . $template )
466                     {
467 0         0             print
468                           "WARNING: Layout file missing: $self->{'layout-path'}/$template\n";
469 0         0             next;
470                     }
471              
472             #
473             # The template-data we'll expand for the page/template.
474             #
475             # (All fields from the page, and from the configuration file.)
476             #
477 2         7         my %data = ( $self->fields(), $page->fields() );
478              
479             #
480             # There may be template filters on templates
481             #
482 2         6         my @filters;
483 2         2         my $filter = $data{ 'template-filter' };
484              
485 2 100       6         if ($filter)
486                     {
487 1         5             foreach my $f ( split( /,/, $filter ) )
488                         {
489 2         6                 $f =~ s/^\s+|\s+$//g;
490 2 50       3                 next unless ($f);
491              
492 2         5                 my $helper = $PLUGINS->filter($f);
493              
494                             push( @filters,
495                                   { sub =>
496 4     4   7934                            sub {my $s = shift; $$s = $helper->filter($$s);},
  4         11  
497 2         9                          format => 'scalar',
498                                   } );
499                         }
500                     }
501              
502             #
503             # Load the HTML::Template module against the layout.
504             #
505                     my $tmpl =
506                       HTML::Template->new(
507                                      filename => $self->{ 'layout-path' } . "/" . $template,
508                                      die_on_bad_params => 0,
509 2         23                          path => [@INCLUDES, $self->{ 'layout-path' }],
510                                      search_path_on_include => 1,
511                                      global_vars => 1,
512                                      loop_context_vars => 1,
513                                      utf8 => 1,
514                                      filter => \@filters,
515                       );
516              
517             #
518             # Use the plugin-factory to expand each of the variables.
519             #
520 2         9148         my $ref = $PLUGINS->expand_variables( $self, $page, \%data );
521 2         19         %data = %$ref;
522              
523              
524 2 50       10         if ( $self->{ 'debug' } )
525                     {
526 0         0             print "Post-expansion variables on : $src\n";
527 0         0             print "\t" . Dumper( \%data );
528                     }
529              
530              
531             #
532             # At this point we can tell if we need to rebuild the page.
533             #
534             # We want to build the page if:
535             #
536             # * The output page is missing.
537             #
538             # * The input page, or any dependancy is newer than the output.
539             #
540 2         7         my $rebuild = 0;
541 2 50       50         $rebuild = 1 if ( !-e $dst );
542              
543 2 50       6         if ( !$rebuild )
544                     {
545              
546             #
547             # Get the dependencies of the page - add in the page source,
548             # and the template path.
549             #
550 0         0             my @deps = ( $self->{ 'layout-path' } . "/" . $template,
551                                      $page->source(), $page->dependencies() );
552              
553 0         0             foreach my $d (@deps)
554                         {
555 0 0       0                 if ( -M $d < -M $dst )
556                             {
557 0 0       0                     $self->{ 'verbose' } &&
558                                   print "Triggering rebuild: $d is more recent than $dst\n";
559 0         0                     $rebuild = 1;
560                             }
561                         }
562                     }
563              
564             #
565             # Forced rebuild via the command-line.
566             #
567 2 50       4         $rebuild = 1 if ( $self->{ 'force' } );
568              
569             #
570             # OK skip if we're not rebuilding, otherwise increase the count.
571             #
572 2 50       4         next unless ($rebuild);
573 2         3         $rebuilt += 1;
574              
575              
576             #
577             # Load the HTML::Template module against the body of the page.
578             #
579             # (Includes are relative to the path of the input.)
580             #
581 2         6         my $dirName = $page->source();
582 2 50       11         if ( $dirName =~ /^(.*)\/(.*)$/ )
583                     {
584 2         6             $dirName = $1;
585                     }
586 2         7         my $body = HTML::Template->new( scalarref => \$page->content( \%data ),
587                                                     die_on_bad_params => 0,
588                                                     path => [@INCLUDES, $dirName],
589                                                     search_path_on_include => 1,
590                                                     global_vars => 1,
591                                                     loop_context_vars => 1,
592                                                     utf8 => 1,
593                                                     filter => \@filters,
594                                                   );
595              
596              
597             #
598             # Template-expand the body of the page.
599             #
600 2         299         $body->param( \%data );
601 2         191         $data{ 'content' } = $body->output();
602              
603              
604             #
605             # Make the (updated) global and per-page data available
606             # to the template object.
607             #
608 2         106         $tmpl->param( \%data );
609              
610             #
611             # Make sure the output path exists.
612             #
613 2         166         my $path = $dst;
614 2 50       13         if ( $path =~ /^(.*)\/(.*)$/ )
615                     {
616 2         4             $path = $1;
617 2 50       29             File::Path::mkpath( $path, { verbose => 0, mode => oct(755) } )
618                           if ( !-d $path );
619                     }
620              
621             #
622             # Output the expanded template to the destination file.
623             #
624 2 50       143         open my $handle, ">:utf8", $dst or die "Failed to write to '$dst' - $!";
625 2         9         binmode( $handle, ":utf8" );
626 2         60         print $handle $tmpl->output();
627 2         237         close $handle;
628                 }
629              
630             #
631             # Cleanup any plugins.
632             #
633 2         15     $PLUGINS->cleanup();
634              
635             #
636             # Return count of rebuilt pages.
637             #
638 2         11     return ($rebuilt);
639             }
640              
641              
642             =head2 copyAssets
643            
644             Copy all assets from the input directory to the output directory.
645            
646             This method will use tar to do so semi-efficiently.
647            
648             =cut
649              
650             sub copyAssets
651             {
652 2     2 1 320     my ($self) = (@_);
653              
654              
655             #
656             # If we're running in-place then we don't need to copy assets.
657             #
658 2 50       7     return if ( $self->{ 'in-place' } );
659              
660             #
661             # The assets.
662             #
663 2         9     my @assets = $self->assets( directory => $self->{ 'input' } );
664              
665             #
666             # The files we're going to copy.
667             #
668 2         3     my @copy;
669              
670              
671             #
672             # We're going to build-up a command line to pass to tar
673             #
674 2         4     foreach my $asset (@assets)
675                 {
676              
677             #
678             # Strip the input component of the filename(s).
679             #
680 10         22         my $src = $asset->source();
681 10         47         $src =~ s/^$self->{'input'}//g;
682              
683             #
684             # Store the destination file path
685             #
686 10         15         my $dst = $asset->source();
687 10         43         $dst =~ s/$self->{'input'}/$self->{'output'}/;
688 10         4         push( @{ $self->{ 'output-files' } }, $dst );
  10         18  
689              
690             #
691             # Filenames must be shell safe: we'll use it in a shell command
692             #
693 10         6         my $quoted_src;
694 10 100       17         if ( $src =~ /\'/ )
695                     {
696 5         5             ( $quoted_src = "$src" ) =~ s{\\}{\\\\}g;
697 5         6             $quoted_src =~ s{\"}{\\\"}g;
698 5         5             $quoted_src =~ s{\$}{\\\$}g;
699 5         4             $quoted_src =~ s{\`}{\\\`}g;
700 5         5             $quoted_src = "\"$quoted_src\"";
701                     }
702                     else
703                     {
704 5         9             $quoted_src = "'$src'";
705                     }
706              
707             #
708             # We only copy asset which do not already exist or which exist but are
709             # newer than in destination (except for directory). If we've got an
710             # asset which is a directory that is already present, for example,
711             # we'll skip it.
712             #
713 10 50 0     135         if ( !-e "$self->{'output'}/$src" ||
      33        
714                          ( !-d "$self->{'output'}/$src" &&
715                             -M "$self->{'output'}/$src" > -M $asset->source() ) )
716                     {
717 10         21             push( @copy, $quoted_src );
718                     }
719                 }
720              
721             #
722             # Run the copy, unless all files are present.
723             #
724 2 50       8     if ( scalar @copy ne 0 )
725                 {
726              
727             #
728             # The horrible command we're going to execute.
729             #
730 2         12         my $cmd = "(cd $self->{'input'} && tar -cf - " .
731                       join( " ", @copy ) . ") | ( cd $self->{'output'} && tar xf -)";
732 2 50       6         print "TAR: $cmd " if ( $self->{ 'verbose' } );
733 2         12331         system($cmd );
734                 }
735             }
736              
737              
738             =head2 sync
739            
740             Delete all files from output directory which do not come from the input directory.
741            
742             =cut
743              
744             sub sync
745             {
746 1     1 1 20     my ($self) = @_;
747              
748 1 50       12     return if $self->{ 'in-place' };
749              
750 1 50       6     return unless $self->{ 'sync' };
751              
752             #
753             # Get list of created and existing files
754             #
755 1         3     my @created = sort @{ $self->{ 'output-files' } };
  1         16  
756              
757                 my @existing = sort
758 5         13       map {$_->source();}
759                   $self->_findFiles( object => "Templer::Site::Asset",
760 1         8                          directory => $self->{ 'output' },
761                                      hide_dotfiles => 0,
762                                    );
763              
764             #
765             # Determine files to remove
766             #
767 1         5     my @files = ();
768 1         4     my @dirs = ();
769 1         2     my %count = ();
770 1         3     foreach ( @created, @existing )
771                 {
772 8         9         $count{ $_ }++;
773                 }
774 1         3     foreach ( keys %count )
775                 {
776 5 100 100     38         push( @files, $_ ) if ( $count{ $_ } == 1 && !-d $_ );
777 5 100 100     24         push( @dirs, $_ ) if ( $count{ $_ } == 1 && -d $_ );
778                 }
779 1         3     @files = sort @files;
780 1         2     @dirs = sort @dirs;
781              
782             #
783             # Removing files
784             #
785 1 50       2     if (@files)
786                 {
787 1 50       4         print "\nRemoving files: @files\n" if ( $self->{ 'verbose' } );
788              
789 1         93         unlink @files;
790                 }
791              
792             #
793             # Removing directories
794             #
795 1 50       9     if (@dirs)
796                 {
797 1 50       8         print "\nRemoving directories: @dirs\n" if ( $self->{ 'verbose' } );
798 1         3         foreach (@dirs)
799                     {
800 1         73             rmdir $_;
801                     }
802                 }
803             }
804              
805             =head2 set
806            
807             Store/update a key/value pair in our internal store.
808            
809             This allows the values passed in the constructor to be updated/added to.
810            
811             =cut
812              
813             sub set
814             {
815 8     8 1 12     my ( $self, $key, $values ) = (@_);
816 8         22     $self->{ $key } = $values;
817             }
818              
819              
820             =head2 fields
821            
822             Get all known key + value pairs from our store.
823            
824             This is called to get all global variables for template interpolation
825             as part of the build. (The global variables and the per-page variables
826             are each fetched and expanded via plugins prior to getting sent to the
827             HTML::Template object.).
828            
829             =cut
830              
831             sub fields
832             {
833 2     2 1 2     my ($self) = (@_);
834              
835 2         13     %$self;
836             }
837              
838              
839             =head2 get
840            
841             Get a single value from our store of variables.
842            
843             =cut
844              
845             sub get
846             {
847 8     8 1 14     my ( $self, $field ) = (@_);
848 8         43     return ( $self->{ $field } );
849             }
850              
851              
852              
853             1;
854