view bin/chronicle @ 46:081d06e16a36

2007-10-10 20:09:24 by steve Added '--force' argument.
author steve
date Wed, 10 Oct 2007 20:09:24 +0000
parents a9f8a82045f8
children 29464ede63dd
line wrap: on
line source

#!/usr/bin/perl -w

=head1 NAME

chronicle - A blog compiler.

=cut

=head1 SYNOPSIS


  Path Options:

   --input       Specify the input directory to use.
   --output      Specify the directory to write output to.
   --templates   Specify the path to the theme templates.
   --pattern     Specify the pattern of files to work with.
   --url-prefix  Specify the prefix to the live blog.


  Pre & Post-Build Commands:

   --pre-build   Specify a command to execute prior to building the blog.
   --post-build  Specify a command to execute once the blog has been built.

  Blog Entry Options:

   --format      Specify the format of your entries, HTML/textile/markdown.

  Optional Features:

   --force       Force the copying of static files from the blog theme.
   --no-archive  Don't create an archive page.
   --no-cache    Don't use the optional memcached features, even if available.
   --no-tags     Don't produce any tag pages.
   --lower-case  Lower-case all filenames which are output.


  Help Options:

   --help        Show the help information for this script.
   --manual      Read the manual for this script.
   --verbose     Show useful debugging information.
   --version     Show the version number and exit.

=cut


=head1 ABOUT

  Chronicle is a simple tool to convert a collection of text files,
 located within a single directory, into a blog consisting of static
 HTML files.

  It supports only the bare minimum of features which are required
 to be useful:

   * Tagging support.

   * RSS support.

   * Archive support.

  The obvious deficiencies are:

   * Lack of support for commenting.

   * Lack of pingback/trackback support.

  Having said that it is a robust, stable, and useful system.

=cut


=head1 BLOG FORMAT

  The format of the text files we process is critical to the output
 pages.  Each entry should look something like this:

=for example begin

    Title:  This is the title of the blog post
    Date:  2nd March 2007
    Tags: one, two, three, long tag

    The text of your entry goes here.

=for example end

  In this example we can see that the entry itself has been prefaced
 with a small header.  The header must contain a 'Title:' line.  The
 'Date:' line is optional, as is the 'Tags:' line.

  The text of the entry itself is assumed to be HTML, however if you
 have the optional modules installed you may write it in Markdown or
 Textile formats.

  Simply add 'format = [ markdown | textile | html]' to the configuration
 file to specify which you wish to use.  (Or use the --format) command
 line argument.

  If you're missing the required Perl module to support your chosen
 input format you will be told this.

=cut


=head1 CONFIGURATION

  The configuration of the software is minimal, and generally performed
 via the command line arguments.  However it is possible to save settings
 either in the file /etc/chroniclerc or the per-user ~/.chroniclerc
 file.

  These files contain lines of the form:

=for example begin

   input  = /home/me/blog

   output = /var/www/blog

   format = markdown

=for example end

  Keys which are unknown are ignored.

=cut


=head1 OPTIONAL CACHING

  To speed the rebuilding of a large blog the compiler may use the
 Memcached deaemon, if installed and available upon the local machine.

  To install this, under a Debian GNU/Linux system please run:

=for example begin

    apt-get update
    apt-get install memcached libcache-memcached-perl

=for example end

  You may disable this caching behaviour with --no-cache, and see the
 effect with --verbose.

=cut


=head1 AUTHOR

 Steve
 --
 http://www.steve.org.uk/

 $Id: chronicle,v 1.20 2007-10-10 20:09:24 steve Exp $

=cut

=head1 LICENSE

Copyright (c) 2007 by Steve Kemp.  All rights reserved.

This module is free software;
you can redistribute it and/or modify it under
the same terms as Perl itself.
The LICENSE file contains the full text of the license.

=cut


use strict;
use warnings;


use Date::Parse;
use Date::Format;
use File::Copy;
use File::Path;
use Getopt::Long;
use HTML::Template;
use Pod::Usage;
use Time::Local;


#
#  Release number
#
#  NOTE:  Set by 'make release'.
#
my $RELEASE = 'UNRELEASED';



#
#  Setup default options.
#
my %CONFIG = setupDefaultOptions();


#
#  Read the global and per-user configuration files, if they exist.
#
readConfigurationFile( "/etc/chroniclerc" );
readConfigurationFile( $ENV{'HOME'} . "/.chroniclerc" );


#
#  Parse the command line arguments.
#
parseCommandLineArguments();


#
#  Should we run something before we start?
#
if ( $CONFIG{'pre-build'} )
{
    $CONFIG{'verbose'} && print "Running command: $CONFIG{'pre-build'}\n";

    system($CONFIG{'pre-build'});
}


#
#  Parse each of the given text files, and build up a datastructure
# we can use to create our pages.
#
#  The data-structure is a hash of arrays.  The hash key is the blog
# entry's filename, and the array stored as the hash's value has
# keys such as:
#
#   tags  => [ 'test', 'testing' ]
#   date  => '1st july 2007'
#   title => 'Some title'
#
#
my %data = createDataStructure();


#
#  Find each unique tag used within our entries.
#
my %all_tags;
%all_tags = findAllTags() unless( $CONFIG{'no-tags'} );


#
#  Find each unique month + year we've used.
#
my %all_dates;
%all_dates = findAllMonths() unless( $CONFIG{'no-archive'} );


#
#  Now create the global tag + date loops which are used for our
# sidebar.
#
my %CLOUD;
$CLOUD{'tag'}     = createTagCloud( %all_tags ) unless( $CONFIG{'no-tags'} );
$CLOUD{'archive'} = createDateCloud( %all_dates ) unless( $CONFIG{'no-archive'} );;



#
#  Create the output directories.
#
mkpath( $CONFIG{'output'}, 0, 0755 ) if ( ! -d $CONFIG{'output'} );
foreach my $tag ( keys %all_tags )
{
    mkpath( "$CONFIG{'output'}/tags/$tag", 0, 0755 );
}
foreach my $date ( keys %all_dates )
{
    next unless ( $date =~ /^([0-9]{4})-([0-9]{2})/ );
    mkpath( "$CONFIG{'output'}/archive/$1/$2", 0, 0755 );
}


#
#  Output each static page.
#
$CONFIG{'verbose'} && print "Creating static pages:\n";
foreach my $file ( keys %data )
{
    outputStaticPage( $file );
}



#
#  Build an output page for each tag which has ever been used.
#
foreach my $tagName ( sort keys %all_tags )
{
    $CONFIG{'verbose'} && print "Creating tag page: $tagName\n";

    outputTagPage( $tagName );
}



#
#  Now build the archives.
#
foreach my $date ( keys( %all_dates ) )
{
    $CONFIG{'verbose'} && print "Creating archive page: $date\n";

    outputArchivePage( $date );
}



#
#  Finally out the most recent entries for the front-page.
#
outputIndexPage();



#
#  Copy any static files into place.
#
copyStaticFiles();


#
#  Post-build command?
#
if ( $CONFIG{'post-build'} )
{
    $CONFIG{'verbose'} && print "Running command: $CONFIG{'post-build'}\n";

    system($CONFIG{'post-build'});
}


#
#  All done.
#
exit;







=begin doc

  Setup the default options we'd expect into our global configuration hash.

=end doc

=cut

sub setupDefaultOptions
{
    my %CONFIG;

    #
    #  Text directory.
    #
    $CONFIG{'input'}      = "./blog";

    #
    #  Output directory.
    #
    $CONFIG{'output'}     = "./output";

    #
    #  Theme directory.
    #
    $CONFIG{'template'}   = "./themes/default";

    #
    #  prefix for all links.
    #
    $CONFIG{'url-prefix'} = "";

    #
    #  Default input format
    #
    $CONFIG{'format'}     = 'html';

    #
    #  Entries per-page for the index.
    #
    $CONFIG{'entry-count'} = 10;

    #
    #  Don't overwrite files by default
    #
    $CONFIG{'force'} = 0;

    return( %CONFIG );
}




=begin doc

  Parse the command line arguments this script was given.

  TODO:  Document these in the POD.

=end doc

=cut

sub parseCommandLineArguments
{
    my $HELP    = 0;
    my $MANUAL  = 0;
    my $VERSION = 0;

    #
    #  Parse options.
    #
    GetOptions(
               # Help options
               "help",         \$HELP,
               "manual",       \$MANUAL,
               "verbose",      \$CONFIG{'verbose'},
               "version",      \$VERSION,

               # paths
               "input=s",      \$CONFIG{'input'},
               "output=s",     \$CONFIG{'output'},
               "templates=s",  \$CONFIG{'template'},
               "pattern=s",    \$CONFIG{'pattern'},

               # optional
               "force",        \$CONFIG{'force'},
               "no-tags",      \$CONFIG{'no-tags'},
               "no-cache",     \$CONFIG{'no-cache'},
               "no-archive",   \$CONFIG{'no-archive'},
               "lower-case",   \$CONFIG{'lower-case'},

               # input format.
               "format=s",     \$CONFIG{'format'},

               # prefix
               "url-prefix=s", \$CONFIG{'url_prefix'},

               # commands
               "pre-build=s",  \$CONFIG{'pre-build'},
               "post-build=s", \$CONFIG{'post-build'},

              );

    pod2usage(1) if $HELP;
    pod2usage(-verbose => 2 ) if $MANUAL;

    if ( $VERSION )
    {
        my $REVISION      = '$Revision: 1.20 $';
        if ( $REVISION =~ /1.([0-9.]+) / )
        {
            $REVISION = $1;
        }

        print( "chronicle release $RELEASE\n" );
        exit;
    }
}



=begin doc

  Create our global datastructure, by reading each of the blog
 files and extracting:

  1.  The title of the entry.

  2.  Any tags which might be present.

  3.  The date upon which it was made.

=end doc

=cut

sub createDataStructure
{
    my %results;

    if ( ! -d $CONFIG{'input'} )
    {
        print <<EOF;

  The blog input directory $CONFIG{'input'} does not exist.

  Aborting.
EOF

        exit
    }

    #
    #  Did the user override the default pattern?
    #
    my $pattern = $CONFIG{'pattern'} || "*";
    my $count   = 0;

    foreach my $file ( sort( glob( $CONFIG{'input'} . "/" . $pattern ) ) )
    {
        #
        #  Ignore directories.
        #
        next if ( -d $file );

        my $title   = '';
        my $date    = '';
        my $private = 0;

        my @tags;

        open( INPUT, "<", $file ) or die "Failed to open blog file $file - $!";
        while( my $line = <INPUT> )
        {
            if ( $line =~ /^tags:(.*)/i )
            {
                my $tag .= $1;
                foreach my $t ( split( /,/, $tag ) )
                {
                    # strip leading and trailing space.
                    $t =~ s/^\s+//;
                    $t =~ s/\s+$//;

                    # skip empty tags.
                    next if ( !length($t) );

                    # lowercase and store the tags.
                    $tag = lc($tag);
                    push ( @tags, $t );
                }
            }
            elsif (( $line =~ /^title:(.*)/i ) && !length($title) )
            {
                $title = $1;

                # strip leading and trailing space.
                $title =~ s/^\s+// if ( length $title );
                $title =~ s/\s+$// if ( length $title );
            }
            elsif (( $line =~ /^date:(.*)/i ) && !length($date) )
            {
                $date = $1;

                # strip leading and trailing space.
                $date =~ s/^\s+// if ( $date );
                $date =~ s/\s+$// if ( $date );

            }
            elsif ( $line =~ /^status:(.*)/i )
            {
                my $level = $1;

                # strip leading and trailing space.
                $level =~ s/^\s+// if ( $level );
                $level =~ s/\s+$// if ( $level );

                $private = 1 if ( $level =~ /private/i);
            }
        }
        close( INPUT );

        $results{$file} = { tags => \@tags,
                            title => $title,
                            date  => $date } unless( $private );

        $count += 1;
    }

    #
    #  Make sure we found some entries.
    #
    if ( $count < 1 )
    {
        print <<EOF;

  There were no text files found in the input directory
 $CONFIG{'input'} which matched the pattern '$pattern'.

  Aborting.

EOF
        exit;
    }

    return %results;
}



=begin doc

  Find each distinct tag which has been used within blog entries,
 and the number of times each one has been used.

=end doc

=cut

sub findAllTags
{
    my %allTags;

    foreach my $f ( keys %data )
    {
        my $h = $data{$f};
        my $tags = $h->{'tags'} || undef;
        foreach my $t ( @$tags )
        {
            $allTags{$t}+=1;
        }
    }

    return( %allTags );
}



=begin doc

  Create a structure for a tag cloud.

=end doc

=cut

sub createTagCloud
{
    my( %unique ) = ( @_ );

    my $results;

    foreach my $key ( sort keys( %unique ) )
    {
        my $count = $unique{$key};
        my $size  = 10 + ( $count * 5 );
        $size = 40 if ( $size >= 40 );

        push( @$results,
            { tag   => $key,
              count => $count,
              size  => $size } );
    }
    return $results;

}



=begin doc

  Find each of the distinct Month + Year pairs for entries which
 have been created.

=end doc

=cut

sub findAllMonths
{
    my %allDates;
    foreach my $f ( keys %data )
    {
        my $h    = $data{$f};
        next if ( !$h );

        my $date = time2str("%Y-%m", str2time($h->{'date'})) || undef;
        next if ( !$date );

        $allDates{$date}+=1;
    }

    return( %allDates );
}



=begin doc

  Create a data structure which can be used for our archive layout.

  This is a little messy too.  It mostly comes because we want to
 have a nested loop so that we can place our entries in a nice manner.

=end doc

=cut

sub createDateCloud
{
    my( %entry_dates ) = ( @_ );

    my $results;
    my $year;
    my $months;
    my @names = qw( January February March April May June July August September October November December );

    foreach my $date ( sort keys %entry_dates )
    {
        next unless ( $date =~ /^([0-9]{4})-([0-9]{2})/ );

        if ( $year and $1 ne $year )
        {
            push( @$results, { year => $year,
                    months => $months } );
            undef $months;
        }
        $year = $1;

        push( @$months, { month => $2,
                month_name => $names[$2-1],
                count => $entry_dates{$date} } );

    }

    push( @$results, { year => $year,
                       months => $months } );

    return $results;
}



=begin doc

  This function will return a hash containing our tag information,
 the values of the hash will be an array of filenames which contain
 that entry.

=end doc

=cut

sub readTagInformation
{
    my( @files ) = (@_);

    my %results;

    foreach my $file ( @files )
    {
        my $tag;
        open( FILE, "<", $file ) or die "Failed to read: $file - $!";
        foreach my $line ( <FILE> )
        {
            next unless $line =~ /^tags:(.*)/i;

            my $tags = $1;

            foreach my $t ( split( /,/ , $tags ) )
            {
                # strip leading and trailing space.
                $t =~ s/^\s+//;
                $t =~ s/\s+$//;

                # skip empty tags.
                next if ( !length($t) );

                # lowercase all tags
                $t = lc($t);

                # Store the filename in the hash for this tag.
                my $cur = $results{$t};
                push @$cur, $file;
                $results{$t} = $cur;
            }
        }
        close( FILE );
    }
    return %results;
}



=begin doc

  Sort by date.

=end doc

=cut

sub bywhen
{
    #
    #  Parse and return the date
    #
    my ($ss1,$mm1,$hh1,$day1,$month1,$year1,$zone1) = strptime($a->{'date'});
    my ($ss2,$mm2,$hh2,$day2,$month2,$year2,$zone2) = strptime($b->{'date'});

    #
    # Abort if we didn't work.
    #
    die "Couldn't find first year"  unless defined($year1);
    die "Couldn't find second year" unless defined($year2);

    #
    #  Convert to compare
    #
    my $c = timelocal(0,0,0,$day1,$month1,$year1 + 1900);
    my $d = timelocal(0,0,0,$day2,$month2,$year2 + 1900);

    return $d <=> $c;
}




=begin doc

  Output the index page + index RSS feed.

=end doc

=cut

sub outputIndexPage
{

    #
    #  Holder for the blog entries.
    #
    my $entries;

    #
    #  Find all the entries and sort them to be most recent first.
    #
    my $tmp;
    foreach my $file ( keys ( %data ) )
    {
        my $blog = readBlogEntry( $file );
        push( @$tmp, $blog ) if (keys( %$blog ) );
    }
    my @tmp2 = sort bywhen @$tmp;


    #
    #  The number of entries to display upon the index.
    #
    my $max = $CONFIG{'entry-count'};
    foreach my $f ( @tmp2 )
    {
        push( @$entries, $f ) if ( $max > 0 );
        $max -= 1;
    }

    #
    #  Open the index template.
    #
    my $template = loadTemplate( "index.template",
                                 die_on_bad_params => 0 );

    #
    #  The entries.
    #
    $template->param( entries => $entries )
      if ( $entries );

    #
    #  The clouds
    #
    $template->param( tagcloud => $CLOUD{'tag'} )
      if ( $CLOUD{'tag'} );
    $template->param( datecloud => $CLOUD{'archive'} )
      if ( $CLOUD{'archive'} );

    #
    #  Blog title and subtitle, if present.
    #
    $template->param( blog_title => $CONFIG{'blog_title'} )
      if ( $CONFIG{'blog_title'} );
    $template->param( blog_subtitle => $CONFIG{'blog_subtitle'} )
      if ( $CONFIG{'blog_subtitle'} );
    $template->param( release => $RELEASE );


    #
    #  Page to use
    #
    my $index = $CONFIG{'filename'} || "index.html";

    outputTemplate( $template, $index );

    #
    #  Output the RSS feed
    #
    $template = loadTemplate( "index.xml.template",
                              die_on_bad_params => 0 );
    $template->param( entries => $entries ) if ( $entries );
    outputTemplate( $template, "index.rss" );
}



=begin doc

  Write out a /tags/$foo/index.html containing each blog entry which has the
 tag '$foo'.

=end doc

=cut

sub outputTagPage
{
    my ( $tagName ) = ( @_ );

    my $dir = "tags/$tagName";

    my %allTags;
    my %tagEntries;
    foreach my $f ( keys %data )
    {
        my $h = $data{$f};
        my $tags = $h->{'tags'} || undef;
        foreach my $t ( @$tags )
        {
            $allTags{$t}+=1;
            my $a = $tagEntries{$t};
            push @$a, $f ;
            $tagEntries{$t}= $a;
        }
    }

    my $matching =  $tagEntries{$tagName};

    my $entries;

    #
    #  Now read the matching entries.
    #
    foreach my $f ( sort @$matching )
    {
        my $blog = readBlogEntry( $f );
        if (keys( %$blog ) )
        {
            $CONFIG{'verbose'} && print "\tAdded: $f\n";
            push( @$entries, $blog );
        }
    }

    #
    #  Now write the output as a HTML page.
    #
    my $template = loadTemplate( "tags.template",
                                 die_on_bad_params => 0 );

    #
    #  The entries.
    #
    $template->param( entries => $entries )   if ( $entries );
    $template->param( tagname => $tagName );

    #
    #  The clouds
    #
    $template->param( tagcloud => $CLOUD{'tag'} )
      if ( $CLOUD{'tag'} );
    $template->param( datecloud => $CLOUD{'archive'} )
      if ( $CLOUD{'archive'} );

    #
    #  Blog title and subtitle, if present.
    #
    $template->param( blog_title => $CONFIG{'blog_title'} )
      if ( $CONFIG{'blog_title'} );
    $template->param( blog_subtitle => $CONFIG{'blog_subtitle'} )
      if ( $CONFIG{'blog_subtitle'} );


    #
    #  Page to use
    #
    my $index = $CONFIG{'filename'} || "index.html";

    outputTemplate( $template, "$dir/$index" );

    #
    #  Now output the .xml file
    #
    $template = loadTemplate( "tags.xml.template", die_on_bad_params => 0 );
    $template->param( entries => $entries ) if ( $entries );
    $template->param( tagname => $tagName ) if ( $tagName );
    outputTemplate( $template, "$dir/$tagName.rss" );
}



=begin doc

  Output the archive page for the given Month + Year.

  This function is a *mess* and iterates over the data structure much
 more often than it needs to.

  TODO:  FIXME

=end doc

=cut

sub outputArchivePage
{
    my( $date ) = ( @_ );

    #
    #  Should we abort?
    #
    if ( $CONFIG{'no-archive'} )
    {
        $CONFIG{'verbose'} && print "Ignoring archive page, as instructed.\n";
        return;
    }


    my $year = '';
    my $month = '';
    if ( $date =~ /^([0-9]{4})-([0-9]{2})/ )
    {
        $year  = $1;
        $month = $2;
    }

    #
    #  Make the directory
    #
    my $dir = "archive/$year/$month";

    my $entries;


    my %dateEntries;
    foreach my $f ( keys %data )
    {
        my $h    = $data{$f};
        my $date = time2str("%Y-%m", str2time($h->{'date'})) || undef;

        push @{$dateEntries{$date}}, $f ;
    }


    my $matching = $dateEntries{$date};
    foreach my $f ( reverse @$matching )
    {
        $CONFIG{'verbose'} && print "\tAdded: $f\n";

        my $blog = readBlogEntry( $f );
        if (keys( %$blog ) )
        {
            push( @$entries, $blog );
        }
    }

    #
    #  Now write the output as a HTML page.
    #
    my $template = loadTemplate( "month.template",
                                 die_on_bad_params => 0 );

    #
    #  The entries.
    #
    $template->param( entries => $entries )   if ( $entries );
    $template->param( year => $year, month => $month );

    #
    #  The clouds
    #
    $template->param( tagcloud => $CLOUD{'tag'} )
      if ( $CLOUD{'tag'} );
    $template->param( datecloud => $CLOUD{'archive'} )
      if ( $CLOUD{'archive'} );

    #
    #  Blog title and subtitle, if present.
    #
    $template->param( blog_title => $CONFIG{'blog_title'} )
      if ( $CONFIG{'blog_title'} );
    $template->param( blog_subtitle => $CONFIG{'blog_subtitle'} )
      if ( $CONFIG{'blog_subtitle'} );

    #
    #  Page to use
    #
    my $index = $CONFIG{'filename'} || "index.html";
    outputTemplate( $template, "$dir/$index" );

    #
    #  Now the RSS page.
    #
    $template = loadTemplate( "month.xml.template", die_on_bad_params => 0 );
    $template->param( entries => $entries ) if ( $entries );
    $template->param( month => $month, year => $year );
    outputTemplate( $template, "$dir/$month.rss" );
}




=begin doc

  Output static page.

=end doc

=cut

sub outputStaticPage
{
    my ( $filename ) = ( @_ );

    #
    #  Load the template
    #
    my $template = loadTemplate( "entry.template",
                                 die_on_bad_params => 0 );

    #
    #  Just the name of the file.
    #
    my $basename = $filename;
    if ( $basename =~ /(.*)\/(.*)/ )
    {
        $basename=$2;
    }

    #
    #  Read the entry
    #
    my $static = readBlogEntry( $filename );

    #
    #  Get the pieces of information.
    #
    my $title = $static->{'title'} || $basename;
    my $tags  = $static->{'tags'};
    my $body  = $static->{'body'};
    my $date  = $static->{'date'} || "";

    $CONFIG{'verbose'} && print "\t$filename\n";

    #
    #  Convert to suitable filename.
    #
    my $file = fileToTitle($title);

    #
    #  The entry.
    #
    $template->param( title => $title );
    $template->param( tags => $tags ) if ( $tags );
    $template->param( date => $date ) if ( $date );
    $template->param( body => $body );

    #
    #  Our clouds
    #
    $template->param( tagcloud => $CLOUD{'tag'} ) if ( $CLOUD{'tag'} );
    $template->param( datecloud => $CLOUD{'archive'} ) if ( $CLOUD{'archive'} );

    #
    #  Blog title and subtitle, if present.
    #
    $template->param( blog_title => $CONFIG{'blog_title'} )
      if ( $CONFIG{'blog_title'} );
    $template->param( blog_subtitle => $CONFIG{'blog_subtitle'} )
      if ( $CONFIG{'blog_subtitle'} );

    outputTemplate( $template, $file );
}



=begin doc

  Return a hash of interesting data from our blog file.

=end doc

=cut

sub readBlogEntry
{
    my ( $filename ) = ( @_);

    my %entry;

    #
    #  Do we have the memcache module available?
    #
    my $cache = undef;
    my $test  = "use Cache::Memcached;";
    eval( $test );
    if ( ( ! $@ )  && ( ! $CONFIG{'no-cache'} ) )
    {
        # create the cache object
        $cache = new Cache::Memcached {'servers' => ["localhost:11211"] };

        # fetch from the cache if it is present.
        my $cached = $cache->get( "file_$filename" );
        if ( defined( $cached ) )
        {
            $CONFIG{'verbose'} && print "memcache-get: $filename\n";
            return( \%$cached )
        }
        else
        {
            $CONFIG{'verbose'} && print "memcache-fail: $filename\n";
        }
    }

    #
    #  I
    #

    my $title  = "";   # entry title.
    my $tags   = "";   # entry tags.
    my $body   = "";   # entry body.
    my $date   = "";   # entry date
    my $status = "";   # entry privacy/security.


    open( ENTRY, "<", $filename ) or die "Failed to read $filename $!";
    while( my $line = <ENTRY> )
    {
        #
        #  Append any tags.
        #
        if ( $line =~ /^tags: (.*)/i )
        {
            $tags .= $1;
        }
        elsif (( $line =~ /^title: (.*)/i ) && !length($title) )
        {
            $title = $1;

            # strip leading and trailing space.
            $title =~ s/^\s+// if ( length $title );
            $title =~ s/\s+$// if ( length $title );
        }
        elsif (( $line =~ /^date: (.*)/i ) && !length($date) )
        {
            $date = $1;

            # strip leading and trailing space.
            $date =~ s/^\s+// if ( length $date );
            $date =~ s/\s+$// if ( length $date );
        }
        elsif (( $line =~ /^status:(.*)/ ) && !length ( $status ) )
        {
            $status = $1;
        }
        else
        {
            $body .= $line;
        }
    }
    close( ENTRY );

    #
    #  Determine the input format to use.
    #
    my $format = lc($CONFIG{'format'});

    #
    #  Now process accordingly.
    #
    if ( $format eq 'html' )
    {
        # nop
    }
    elsif( $format eq 'markdown' )
    {
        $body = markdown2HTML( $body );
    }
    elsif( $format eq 'textile' )
    {
        $body = textile2HTML( $body );
    }
    else
    {
        print "Unkown blog entry format ($CONFIG{'format'}).\n";
        print "Treating as HTML.\n";
    }

    #
    #
    #  If we have tags then we should use them.
    #
    my $entryTags;

    foreach my $tag ( split( /,/, $tags ) )
    {
        # strip leading and trailing space.
        $tag =~ s/^\s+//;
        $tag =~ s/\s+$//;

        # skip empty tags.
        next if ( !length($tag) );
        $tag = lc($tag);
        push ( @$entryTags, { tag => $tag } );
    }

    #
    #  Get the link
    #
    my $link = fileToTitle( $title );

    #
    #  If the date isn't set then use todays.
    #
    if ( ! defined($date) ||( !length( $date ) ) )
    {
        my @abbr = qw( Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov De
c );
        my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) =
          localtime(time);

        $year += 1900;

        $date = "$mday $abbr[$mon] $year";
    }

    #
    #  Store the entry.
    #
    $entry{'title'} = $title;
    $entry{'body'}  = $body if ( $body );
    $entry{'link'}  = $link;
    $entry{'date'}  = $date;
    $entry{'tags'}  = $entryTags if ( $entryTags );

    #
    #  Store the read file in the cache if we're using it.
    #
    if ( defined( $cache ) )
    {
        $CONFIG{'verbose'} && print "memcache-set: $filename\n";
        $cache->set( "file_$filename", \%entry );
    }
    return \%entry;
}



=begin doc

  Create a filename for an URL which does not contain unsafe
 characters.

=end doc

=cut

sub fileToTitle
{
    my( $file ) = ( @_ );

    if ( $file =~ /(.*)\.(.*)/ )
    {
        $file = $1;
    }
    $file =~ s/ /_/g;
    $file =~ s/\///g;
    $file =~ s/\\//g;

    my $suffix = $CONFIG{'suffix'} ||".html";
    $file .= $suffix;

    #
    #  Lower case?
    #
    $file = lc($file) if ( $CONFIG{'lower-case'} );

    return( $file );

}



=begin doc

  Load a template file.

=end doc

=cut

sub loadTemplate
{
    my( $file, %params ) = (@_);

    #
    #  Make sure the file exists.
    #
    if ( ! -e $CONFIG{'template'} . "/" . $file )
    {
        print <<EOF;

  The template file $file was not found in our template directory
 of $CONFIG{'template'}.

  Aborting.
EOF
        exit;
    }

    my $t = HTML::Template->new( filename          => $file,
                                 path              => $CONFIG{'template'},
                                 loop_context_vars => 1,
                                 global_vars       => 1,
                                 %params );

    return( $t );
}



=begin doc

  Set URL for top directory and output a template.

=end doc

=cut

sub outputTemplate
{
    my( $template, $path ) = ( @_ );

    #
    # Select relative/absolute URL prefix.
    #
    my $top;
    if ( $CONFIG{'url_prefix'} )
    {
        $top = $CONFIG{'url_prefix'};
    }
    else
    {
        $top = $path;
        $top =~ s'[^/]+/'../'g;
        $top =~ s'[^/]*$'';
    }
    $template->param( top => $top );

    open( OUTPUT, ">", "$CONFIG{'output'}/$path" );
    print OUTPUT $template->output();
    close( OUTPUT );
}



=begin doc

  Read the specified configuration file if it exists.

=end doc

=cut

sub readConfigurationFile
{
    my( $file ) = ( @_ );

    #
    #  If it doesn't exist ignore it.
    #
    return if ( ! -e $file );


    my $line = "";

    open( FILE, "<", $file ) or die "Cannot read file '$file' - $!";
    while (defined($line = <FILE>) )
    {
        chomp $line;
        if ($line =~ s/\\$//)
        {
            $line .= <FILE>;
            redo unless eof(FILE);
        }

        # Skip lines beginning with comments
        next if ( $line =~ /^([ \t]*)\#/ );

        # Skip blank lines
        next if ( length( $line ) < 1 );

        # Strip trailing comments.
        if ( $line =~ /(.*)\#(.*)/ )
        {
            $line = $1;
        }

        # Find variable settings
        if ( $line =~ /([^=]+)=([^\n]+)/ )
        {
            my $key = $1;
            my $val = $2;

            # Strip leading and trailing whitespace.
            $key =~ s/^\s+//;
            $key =~ s/\s+$//;
            $val =~ s/^\s+//;
            $val =~ s/\s+$//;

            # command expansion?
            if ( $val =~ /(.*)`([^`]+)`(.*)/ )
            {
                # store
                my $pre  = $1;
                my $cmd  = $2;
                my $post = $3;

                # get output
                my $output = `$cmd`;
                chomp( $output );

                # build up replacement.
                $val = $pre . $output . $post;
            }

            # Store value.
            $CONFIG{ $key } = $val;
        }
    }

    close( FILE );
}



=begin doc

  Copy any static files from the theme directory into the "live" location
 in the output.

  This only works for a top-level target directory.

  Unless --force is specified we skip copying files which already exist.

=end doc

=cut

sub copyStaticFiles
{
    #
    #  Soure and destination for the copy
    #
    my $input  = $CONFIG{'template'};
    my $output = $CONFIG{'output'};

    foreach my $pattern ( qw! *.css *.jpg *.gif *.png *.js *.ico ! )
    {
        foreach my $file ( glob( $input . "/" . $pattern ) )
        {
            #
            #  Get the name of the file.
            #
            if ( $file =~ /(.*)\/(.*)/ )
            {
                $file = $2;
            }
            if ( $CONFIG{'force'} || ( ! -e "$output/$file" ) )
            {
                $CONFIG{'verbose'} && print "Copying static file: $file\n";
                copy(  "$input/$file", "$output/$file" );
            }
        }
    }
}



=begin doc

  Convert from markdown to HTML.

=end doc

=cut

sub markdown2HTML
{
    my( $text ) = (@_);

    #
    #  Make sure we have the module installed.  Use eval to
    # avoid making this mandatory.
    #
    my $test    = "use Text::Markdown;";

    #
    #  Test loading the module.
    #
    eval( $test );
    if ( $@ )
    {
        print <<EOF;

  You have chosen to format your input text via Markdown, but the
 Perl module Text::Markdown is not installed.

  Aborting.
EOF
        exit;
    }

    #
    #  Convert.
    #
    $text = Text::Markdown::Markdown( $text );
    return( $text );
}



=begin doc

  Convert from textile to HTML.

=end doc

=cut

sub textile2HTML
{
    my( $text ) = (@_);

    #
    #  Make sure we have the module installed.  Use eval to
    # avoid making this mandatory.
    #
    my $test    = "use Text::Textile;";

    #
    #  Test loading the module.
    #
    eval( $test );
    if ( $@ )
    {
        print <<EOF;

  You have chosen to format your input text via Textile, but the
 Perl module Text::Textile is not installed.

  Aborting.
EOF
        exit;
    }

    #
    #  Convert.
    #
    $text = Text::Textile::textile( $text );
    return( $text );
}