Move static files into separate staticroot directory
[exim-website.git] / script / gen.pl
1 #!/usr/bin/env perl
2 #
3 use strict;
4 use warnings;
5
6 use CSS::Minifier::XS 0.07;
7 use File::Copy;
8 use File::Find;
9 use File::Path qw(make_path);
10 use File::Slurp;
11 use File::Spec;
12 use Getopt::Long;
13 use JavaScript::Minifier::XS;
14 use Pod::Usage;
15 use XML::LibXML;
16 use XML::LibXSLT;
17
18 my $canonical_url = 'http://www.exim.org/';
19
20 ## Parse arguments
21 my %opt = parse_arguments();
22
23 ## setup static root location
24 ## TODO: for doc generation only this should be within the docs dir
25 $opt{staticroot} = File::Spec->catdir( $opt{docroot}, 'static' );
26
27 ## Generate the pages
28 my %cache;    # General cache object
29 do_doc( 'spec',   $_ ) foreach @{ $opt{spec}   || [] };
30 do_doc( 'filter', $_ ) foreach @{ $opt{filter} || [] };
31 do_web() if ( $opt{web} );
32 do_static();    # need this for all other pages generated
33
34 ## Add the exim-html-current symlink
35 print "Symlinking exim-html-current to exim-html-$opt{latest}\n" if ( $opt{verbose} );
36 unlink("$opt{docroot}/exim-html-current") if ( -l "$opt{docroot}/exim-html-current" );
37 symlink( "exim-html-$opt{latest}", "$opt{docroot}/exim-html-current" )
38     || die "symlink to $opt{docroot}/exim-html-current failed";
39
40 # ------------------------------------------------------------------
41 ## Generate the website files
42 sub do_web {
43
44     ## copy these templates to docroot...
45     copy_transform_files( "$opt{tmpl}/web", $opt{docroot}, 0 );
46 }
47
48 # ------------------------------------------------------------------
49 ## Generate the static file set
50 sub do_static {
51
52     ## make sure I have a directory
53     mkdir( $opt{staticroot} ) or die "Unable to make staticroot: $!\n" unless ( -d $opt{staticroot} );
54
55     ## copy these templates to docroot...
56     copy_transform_files( "$opt{tmpl}/static", $opt{staticroot}, 1 );
57 }
58
59 # ------------------------------------------------------------------
60 ## Generate the website files
61 sub copy_transform_files {
62     my $source = shift;
63     my $target = shift;
64     my $static = shift;
65
66     ## Make sure the template web directory exists
67     die "No such directory: $source\n" unless ( -d $source );
68
69     ## Scan the web templates
70     find(
71         sub {
72             my ($path) = substr( $File::Find::name, length("$source"), length($File::Find::name) ) =~ m#^/*(.*)$#;
73
74             if ( -d "$source/$path" ) {
75
76                 ## Create the directory in the target if it doesn't exist
77                 if ( !-d "$target/$path" ) {
78                     mkdir("$target/$path") or die "Unable to make $target/$path: $!\n";
79                 }
80
81             }
82             else {
83
84                 ## Build HTML from XSL files and simply copy static files which have changed
85                 if ( ( !$static ) and ( $path =~ /(.+)\.xsl$/ ) ) {
86                     print "Generating  : /$1.html\n" if ( $opt{verbose} );
87                     transform( undef, "$source/$path", "$target/$1.html" );
88                 }
89                 elsif ( -f "$source/$path" ) {
90
91                     ## Skip if the file hasn't changed (mtime/size based)
92                     return
93                         if (( -f "$target/$path" )
94                         and ( ( stat("$source/$path") )[9] == ( stat("$target/$path") )[9] )
95                         and ( ( stat("$source/$path") )[7] == ( stat("$target/$path") )[7] ) );
96
97                     if ( $path =~ /(.+)\.css$/ ) {
98                         print "CSS to  : /$path\n" if ( $opt{verbose} );
99                         my $content = read_file("$source/$path");
100                         write_file( "$target/$path", $opt{minify} ? CSS::Minifier::XS::minify($content) : $content );
101                     }
102                     elsif ( $path =~ /(.+)\.js$/ ) {
103                         print "JS to  : /$path\n" if ( $opt{verbose} );
104                         my $content = read_file("$source/$path");
105                         write_file( "$target/$path",
106                             $opt{minify} ? JavaScript::Minifier::XS::minify($content) : $content );
107                     }
108                     else {
109                         ## Copy
110                         print "Copying to  : /$path\n" if ( $opt{verbose} );
111                         copy( "$source/$path", "$target/$path" ) or die "$path: $!";
112                     }
113                     ## Set mtime
114                     utime( time, ( stat("$source/$path") )[9], "$target/$path" );
115                 }
116             }
117
118         },
119         "$source"
120     );
121 }
122
123 # ------------------------------------------------------------------
124 ## Generate index/chapter files for a doc
125 sub do_doc {
126     my ( $type, $xml_path ) = @_;
127
128     ## Read and validate the XML file
129     my $xml = XML::LibXML->new()->parse_file($xml_path) or die $!;
130
131     ## Get the version number
132     my $version = $xml->findvalue('/book/bookinfo/revhistory/revision/revnumber');
133     die "Unable to get version number\n" unless defined $version && $version =~ /^\d+(\.\d+)*$/;
134
135     ## Prepend chapter filenames?
136     my $prepend_chapter = $type eq 'filter' ? 'filter_' : '';
137
138     ## Add the canonical url for this document
139     $xml->documentElement()
140         ->appendTextChild( 'canonical_url',
141         "${canonical_url}exim-html-current/doc/html/spec_html/" . ( $type eq 'spec' ? 'index' : 'filter' ) . ".html" );
142
143     ## Add a url for the latest version of this document
144     if ( $version ne $opt{latest} ) {
145         $xml->documentElement()
146             ->appendTextChild( 'current_url',
147             "../../../../exim-html-current/doc/html/spec_html/" . ( $type eq 'spec' ? 'index' : 'filter' ) . ".html" );
148     }
149
150     ## Fixup the XML
151     xref_fixup( $xml, $prepend_chapter );
152
153     ## Generate the front page
154     {
155         my $path = "exim-html-$version/doc/html/spec_html/" . ( $type eq 'filter' ? $type : 'index' ) . ".html";
156         print "Generating  : docroot:/$path\n" if ( $opt{verbose} );
157         transform( $xml, "$opt{tmpl}/doc/index.xsl", "$opt{docroot}/$path", );
158     }
159
160     ## Generate a Table of Contents XML file
161     {
162         my $path =
163             "exim-html-$version/doc/html/spec_html/" . ( $type eq 'filter' ? 'filter_toc' : 'index_toc' ) . ".xml";
164         print "Generating  : docroot:/$path\n" if ( $opt{verbose} );
165         transform( $xml, "$opt{tmpl}/doc/toc.xsl", "$opt{docroot}/$path", );
166     }
167
168     ## Generate the chapters
169     my $counter = 0;
170     my @chapters = map { $_->cloneNode(1) } $xml->findnodes('/book/chapter');
171     foreach my $chapter (@chapters) {
172
173         ## Add a <chapter_id>N</chapter_id> node for the stylesheet to use
174         $chapter->appendTextChild( 'chapter_id', ++$counter );
175
176         ## Add previous/next/canonical urls for nav
177         {
178             $chapter->appendTextChild( 'prev_url',
179                   $counter == 1
180                 ? $type eq 'filter'
181                         ? 'filter.html'
182                         : 'index.html'
183                 : sprintf( '%sch%02d.html', $prepend_chapter, $counter - 1 ) );
184             $chapter->appendTextChild( 'next_url', sprintf( '%sch%02d.html', $prepend_chapter, $counter + 1 ) )
185                 unless int(@chapters) == $counter;
186             $chapter->appendTextChild( 'toc_url', ( $type eq 'filter' ? 'filter' : 'index' ) . '.html' );
187             $chapter->appendTextChild(
188                 'canonical_url',
189                 sprintf(
190                     'http://www.exim.org/exim-html-current/doc/html/spec_html/%sch%02d.html',
191                     $prepend_chapter, $counter
192                 )
193             );
194             if ( $version ne $opt{latest} ) {
195                 $chapter->appendTextChild(
196                     'current_url',
197                     sprintf(
198                         '../../../../exim-html-current/doc/html/spec_html/%sch%02d.html',
199                         $prepend_chapter, $counter
200                     )
201                 );
202             }
203         }
204
205         ## Create an XML document from the chapter
206         my $doc = XML::LibXML::Document->createDocument( '1.0', 'UTF-8' );
207         $doc->setDocumentElement($chapter);
208
209         ## Transform the chapter into html
210         {
211             my $path = sprintf( 'exim-html-%s/doc/html/spec_html/%sch%02d.html', $version, $prepend_chapter, $counter );
212             print "Generating  : docroot:/$path\n" if ( $opt{verbose} );
213             transform( $doc, "$opt{tmpl}/doc/chapter.xsl", "$opt{docroot}/$path", );
214         }
215     }
216 }
217
218 # ------------------------------------------------------------------
219 ## Fixup xref tags
220 sub xref_fixup {
221     my ( $xml, $prepend_chapter ) = @_;
222
223     my %index = ();
224
225     ## Add the "prepend_chapter" info
226     ( $xml->findnodes('/book') )[0]->appendTextChild( 'prepend_chapter', $prepend_chapter );
227
228     ## Iterate over each chapter
229     my $chapter_counter = 0;
230     foreach my $chapter ( $xml->findnodes('/book/chapter') ) {
231         ++$chapter_counter;
232
233         my $chapter_id = $chapter->getAttribute('id');
234         unless ($chapter_id) {    # synthesise missing id
235             $chapter_id = sprintf( 'chapter_noid_%04d', $chapter_counter );
236             $chapter->setAttribute( 'id', $chapter_id );
237         }
238         my $chapter_title = $chapter->findvalue('title');
239
240         $index{$chapter_id} = { chapter_id => $chapter_counter, chapter_title => $chapter_title };
241
242         ## Iterate over each section
243         my $section_counter = 0;
244         foreach my $section ( $chapter->findnodes('section') ) {
245             ++$section_counter;
246
247             my $section_id = $section->getAttribute('id');
248             unless ($section_id) {    # synthesise missing id
249                 $section_id = sprintf( 'section_noid_%04d_%04d', $chapter_counter, $section_counter );
250                 $section->setAttribute( 'id', $section_id );
251             }
252             my $section_title = $section->findvalue('title');
253
254             $index{$section_id} = {
255                 chapter_id    => $chapter_counter,
256                 chapter_title => $chapter_title,
257                 section_id    => $section_counter,
258                 section_title => $section_title
259             };
260         }
261     }
262     ## Build indexes as new chapters
263     build_indexes( $xml, $prepend_chapter, \%index );
264
265     ## Replace all of the xrefs in the XML
266     foreach my $xref ( $xml->findnodes('//xref') ) {
267         my $linkend = $xref->getAttribute('linkend');
268         if ( exists $index{$linkend} ) {
269             $xref->setAttribute( 'chapter_id',    $index{$linkend}{'chapter_id'} );
270             $xref->setAttribute( 'chapter_title', $index{$linkend}{'chapter_title'} );
271             $xref->setAttribute( 'section_id', $index{$linkend}{'section_id'} ) if ( $index{$linkend}{'section_id'} );
272             $xref->setAttribute( 'section_title', $index{$linkend}{'section_title'} )
273                 if ( $index{$linkend}{'section_title'} );
274             $xref->setAttribute( 'url',
275                 sprintf( '%sch%02d.html', $prepend_chapter, $index{$linkend}{'chapter_id'} )
276                     . ( $index{$linkend}{'section_id'} ? '#' . $linkend : '' ) );
277         }
278     }
279 }
280
281 # ------------------------------------------------------------------
282 ## Build indexes
283 sub build_indexes {
284     my ( $xml, $prepend_chapter, $xref ) = @_;
285
286     my $index_hash = {};
287     my $current_id;
288     foreach my $node ( $xml->findnodes('//section | //chapter | //indexterm') ) {
289         if ( $node->nodeName eq 'indexterm' ) {
290             my $role      = $node->getAttribute('role') || 'concept';
291             my $primary   = $node->findvalue('child::primary');
292             my $first     = ( $primary =~ /^[A-Za-z]/ ) ? uc( substr( $primary, 0, 1 ) ) : '';  # first letter or marker
293             my $secondary = $node->findvalue('child::secondary') || '';
294             next unless ( $primary || $secondary );    # skip blank entries for now...
295             $index_hash->{$role}{$first}{$primary}{$secondary} ||= [];
296             push @{ $index_hash->{$role}{$first}{$primary}{$secondary} }, $current_id;
297         }
298         else {
299             $current_id = $node->getAttribute('id');
300         }
301     }
302
303     # now we build a set of new chapters with the index data in
304     my $book = ( $xml->findnodes('/book') )[0];
305     foreach my $role ( sort { $a cmp $b } keys %{$index_hash} ) {
306         my $chapter = XML::LibXML::Element->new('chapter');
307         $book->appendChild($chapter);
308         $chapter->setAttribute( 'id', join( '_', 'index', $role ) );
309         $chapter->setAttribute( 'class', 'index' );
310         $chapter->appendTextChild( 'title', ( ucfirst($role) . ' Index' ) );
311         foreach my $first ( sort { $a cmp $b } keys %{ $index_hash->{$role} } ) {
312             my $section = XML::LibXML::Element->new('section');
313             my $list    = XML::LibXML::Element->new('variablelist');
314             $chapter->appendChild($section);
315             $section->setAttribute( 'id', join( '_', 'index', $role, $first ) );
316             $section->setAttribute( 'class', 'index' );
317             $section->appendTextChild( 'title', $first ? $first : 'Symbols' );
318             $section->appendChild($list);
319             foreach my $primary ( sort { $a cmp $b } keys %{ $index_hash->{$role}{$first} } ) {
320                 my $entry = XML::LibXML::Element->new('varlistentry');
321                 my $item  = XML::LibXML::Element->new('listitem');
322                 $list->appendChild($entry)->appendTextChild( 'term', $primary );
323                 $entry->appendChild($item);
324                 my $slist;
325                 foreach my $secondary ( sort { $a cmp $b } keys %{ $index_hash->{$role}{$first}{$primary} } ) {
326                     my $para = XML::LibXML::Element->new('para');
327                     if ( $secondary eq '' ) {
328                         $item->appendChild($para);    # skip having extra layer of heirarchy
329                     }
330                     else {
331                         unless ($slist) {
332                             $slist = XML::LibXML::Element->new('variablelist');
333                             $item->appendChild($slist);
334                         }
335                         my $sentry = XML::LibXML::Element->new('varlistentry');
336                         my $sitem  = XML::LibXML::Element->new('listitem');
337                         $slist->appendChild($sentry)->appendTextChild( 'term', $secondary );
338                         $sentry->appendChild($sitem)->appendChild($para);
339                     }
340                     my $count = 0;
341                     foreach my $ref ( @{ $index_hash->{$role}{$first}{$primary}{$secondary} } ) {
342                         $para->appendText(', ')
343                             if ( $count++ );
344                         my $xrefel = XML::LibXML::Element->new('xref');
345                         $xrefel->setAttribute( linkend => $ref );
346                         $xrefel->setAttribute( longref => 1 );
347                         $para->appendChild($xrefel);
348                     }
349                 }
350             }
351         }
352     }
353 }
354
355 # ------------------------------------------------------------------
356 ## Handle the transformation
357 sub transform {
358     my ( $xml, $xsl_path, $out_path ) = @_;
359
360     ## Build an empty XML structure if an undefined $xml was passed
361     unless ( defined $xml ) {
362         $xml = XML::LibXML::Document->createDocument( '1.0', 'UTF-8' );
363         $xml->setDocumentElement( $xml->createElement('content') );
364     }
365
366     ## Add the current version of Exim to the XML
367     $xml->documentElement()->appendTextChild( 'current_version', $opt{latest} );
368
369     ## Add the old versions of Exim to the XML
370     $xml->documentElement()->appendTextChild( 'old_versions', $_ ) foreach old_docs_versions();
371
372     ## Parse the ".xsl" file as XML
373     my $xsl = XML::LibXML->new()->parse_file($xsl_path) or die $!;
374
375     ## Generate a stylesheet from the ".xsl" XML.
376     my $stylesheet = XML::LibXSLT->new()->parse_stylesheet($xsl);
377
378     ## work out the static root relative to the target
379     my $target_dir = ( File::Spec->splitpath($out_path) )[1];
380     my $staticroot = File::Spec->abs2rel( $opt{staticroot}, $target_dir );
381
382     ## Generate a doc from the XML transformed with the XSL
383     my $doc = $stylesheet->transform( $xml, staticroot => sprintf( "'%s'", $staticroot ) );
384
385     ## Make the containing directory if it doesn't exist
386     make_path( ( $out_path =~ /^(.+)\/.+$/ )[0], { verbose => $opt{verbose} } );
387
388     ## Write out the document
389     open my $out, '>', $out_path or die "Unable to write $out_path - $!";
390     print $out $stylesheet->output_as_bytes($doc);
391     close $out;
392 }
393
394 # ------------------------------------------------------------------
395 ## Look in the docroot for old versions of the documentation
396 sub old_docs_versions {
397     if ( !exists $cache{old_docs_versions} ) {
398         my @versions;
399         foreach ( glob("$opt{docroot}/exim-html-*") ) {
400             push @versions, $1 if /-(\d+(?:\.\d+)?)$/ && $1 < $opt{latest} && -d $_;
401         }
402         $cache{old_docs_versions} = [ reverse sort { $a <=> $b } @versions ];
403     }
404     return @{ $cache{old_docs_versions} };
405 }
406
407 # ------------------------------------------------------------------
408 ## error_help
409 sub error_help {
410     my $msg = shift;
411
412     warn $msg;
413     pod2usage( -exitval => 1, -verbose => 0 );
414 }
415
416 # ------------------------------------------------------------------
417 ## Parse arguments
418 sub parse_arguments {
419
420     my %opt = ( spec => [], filter => [], help => 0, man => 0, web => 0, minify => 1, verbose => 0 );
421     GetOptions(
422         \%opt,      'help|h!', 'man!',      'web!',    'spec=s{1,}', 'filter=s{1,}',
423         'latest=s', 'tmpl=s',  'docroot=s', 'minify!', 'verbose!'
424     ) || pod2usage( -exitval => 1, -verbose => 0 );
425
426     ## --help
427     pod2usage(0) if ( $opt{help} );
428     pod2usage( -verbose => 2 ) if ( $opt{man} );
429
430     ## --spec and --filter lists
431     foreach my $set (qw[spec filter]) {
432         $opt{$set} =
433             [ map { my $f = File::Spec->rel2abs($_); help( 1, 'No such file: ' . $_ ) unless -f $f; $f }
434                 @{ $opt{$set} } ];
435     }
436     ## --latest
437     error_help('Missing value for latest') unless ( exists( $opt{latest} ) && defined( $opt{latest} ) );
438     error_help('Invalid value for latest') unless $opt{latest} =~ /^\d+(?:\.\d+)*$/;
439
440     ## --tmpl and --docroot
441     foreach my $set (qw[tmpl docroot]) {
442         error_help( 'Missing value for ' . $set ) unless ( exists( $opt{$set} ) && defined( $opt{$set} ) );
443         my $f = File::Spec->rel2abs( $opt{$set} );
444         error_help( 'No such directory: ' . $opt{$set} ) unless -d $f;
445         $opt{$set} = $f;
446     }
447     error_help('Excess arguments') if ( scalar(@ARGV) );
448
449     error_help('Must include at least one of --web, --spec or --filter')
450         unless ( $opt{web} || scalar( @{ $opt{spec} || [] } ) || scalar( @{ $opt{filter} || [] } ) );
451
452     return %opt;
453 }
454
455 # ------------------------------------------------------------------
456 1;
457
458 __END__
459
460 =head1 NAME
461
462 gen.pl - Generate exim html documentation and website
463
464 =head1 SYNOPSIS
465
466 gen.pl [options]
467
468  Options:
469    --help              display this help and exits
470    --man               displays man page
471    --spec file...      spec docbook/XML source files
472    --filter file...    filter docbook/XML source files
473    --web               Generate the general website pages
474    --latest VERSION    Required. Specify the latest stable version of Exim.
475    --tmpl PATH         Required. Path to the templates directory
476    --docroot PATH      Required. Path to the website document root
477    --[no-]minify       [Don't] minify CSS and Javascript    
478
479 =head1 OPTIONS
480
481 =over 4
482
483 =item B<--help>
484
485 Display help and exits
486
487 =item B<--man>
488
489 Display man page
490
491 =item B<--spec> I<file...>
492
493 List of files that make up the specification documentation
494 docbook/XML source files.
495
496 =item B<--filter> I<file...>
497
498 List of files that make up the filter documentation docbook/XML
499 source files.
500
501 =item B<--web>
502
503 Generate the website from the template files.
504
505 =item B<--latest> I<version>
506
507 Specify the current exim version. This is used to create links to
508 the current documentation.
509
510 This option is I<required>
511
512 =item B<--tmpl> I<directory>
513
514 Specify the directory that the templates are kept in.
515
516 This option is I<required>
517
518 =item B<--docroot> I<directory>
519
520 Specify the directory that the output should be generated into.
521 This is the website C<docroot> directory.
522
523 This option is I<required>
524
525 =item B<--minify>
526
527 If this option is set then both the CSS and Javascript files
528 processed are minified using L<CSS::Minifier::XS> and
529 L<JavaScript::Minifier::XS> respectively.
530
531 This option is set by default - to disable it specify C<--no-minify>
532
533 =back
534
535 =head1 DESCRIPTION
536
537 Generates the exim website and HTML documentation.
538
539 =head1 EXAMPLE
540
541     script/gen.pl \
542       --web \
543       --spec docbook/*/spec.xml \
544       --filter  docbook/*/filter.xml \
545       --latest 4.72 \
546       --tmpl templates \
547       --docroot /tmp/website
548
549 =head1 AUTHOR
550
551 Mike Cardwell
552
553 Nigel Metheringham <nigel@exim.org> - mostly broke the framework
554 Mike produced.
555
556 =head1 COPYRIGHT
557
558 Copyright 2010-2012 Exim Maintainers. All rights reserved.
559
560 =cut