#!/usr/bin/perl

=head1 NAME

chronicle - A static blog-compiler.

=cut

=head1 SYNOPSIS

  chronicle [options]


  Path Options:

   --comments       Specify the path to the optional comments directory.
   --config         Specify a configuration file to read.
   --database       Specify the path to the SQLite database to create/use.
   --input          Specify the input directory to use.
   --output         Specify the directory to write output to.
   --pattern        Specify the pattern of files to work with.
   --theme          Specify the theme to use.
   --theme-dir      Specify the path to the theme templates.
   --url-prefix     Specify the prefix to the generated blog.
   --template-engine Specify the template system to use
                    (HTMLTemplate (default), Xslate or XslateTT)

  Counting Options:

   --comment-days=N    The maximum age a post may allow comments.
   --entry-count=N     Number of posts to show on the index.
   --rss-count=N       Number of posts to include on the RSS index feed.

  Optional Features:

   --author        Specify the author's email address.
   --blog-subtitle Set the title of the blog.
   --blog-title    Set the title of the blog.
   --force         Always regenerate pages.
   --lower-case    Write only lower-case post-files.
   --unicode=<yes|no|mac>
                   Allow non-ASCII characters in file names. Default is
                   `no'. Use `mac' if serving files off an HFS+ volume.

  Help Options:

   --help         Show the help information for this script.
   --list-plugins List the available plugins.
   --list-themes  List the available themes.
   --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 blog-compiler which will convert a directory full of
plain-text blog-posts into a fully-featured HTML website containing
posts, tags, and archives.

All blog-posts from a given input directory are parsed into a SQLite
database which is then used to generate the output pages.

The SQLite database is assumed to persist, such that it will be updated
if new posts are written, or previous posts are updated.  However if
it is removed it will be recreated when needed.

=cut

=head1 DATABASE STRUCTURE

When C<chronicle> is first executed it will create an SQLite database
if it is not already present.  The database will contain two tables,
one for the posts, and one to store the tags associated with the posts,
if you choose to use tags in your entries.

The blog-entry table contains the following columns:

=over 8

=item mtime

The C<mtime> of the input file.

=item date

The date-header as self-reported in the blog-post.

=item body

The body of the blog-post itself.

=item title

The title of the blog-post itself.

=back

If you wish to add extra tables via a local plugin you're welcome to do so.

=cut

=head1 EXTENDING WITH PLUGINS

The main driver, chronicle, is responsible for only a few small jobs:

=over 8

=item Finding Blog Posts.

By default C<data/*.txt> are read, but you may adjust the input directory via the C<--input> command-line flag.  The pattern may be set with C<--pattern>.

B<NOTE> The pattern is applied recursively, if you wish to create sub-directories with your posts inside them for organizational purposes.

=item Inserting them into the SQLite database.

The header is read to look for things such as the post-date, the subject,
and the tags.  The body is imported literally, unless expanded and reformatted
via a plugin.

=item Executing plugins

Each registered plugin will be invoked in turn, allowing the various
output parts to be generated.

=back

The output is exclusively generated by the plugins bundled with the
code.

For example all of the tag-pages, located beneath C</tags/> in your
 generated site, are generated by the C<Chronicle::Plugin::Generate::Tags> module.

The core will call the following methods if present in plugins:

=over 8

=item on_db_create

This is called if the SQLite database does not exist and must be created.  This method can be used to add new columns or tables to the database, etc.

=item on_db_open

This is called when an existing SQLite database is opened, and we use it to set memory/sync options.

=item on_insert

This method is invoked as a blog entry is read to disk before it is inserted into the database for the first time - or when the item on disk has been changed and the database entry must be refreshed.

This method is the perfect place to handle format conversion, which is demonstrated in the following plugins:

=over 8

=item L<Chronicle::Plugin::Markdown>

=item L<Chronicle::Plugin::MultiMarkdown>

=item L<Chronicle::Plugin::Textile>

=back

Beyond format conversion this method is also good for expanding macros, or snippets of HTML.  (This is done by L<Chronicle::Plugin::YouTube> for example.)

=item on_initiate

This is called prior to any generation, with a reference to the configuration
options and the database handle used for storage.

=item on_generate

This is called to generate the output pages.  There is no logical difference between this method and C<on_initiate> except that the former plugin methods are guaranteed to have been called prior to C<on_generate> being invoked.

Again a reference to the configuration options, and the database handle is provided.

=back

Any plugin in the C<Chronicle::Plugin::> namespace will be loaded when the
script starts.

You might wish to disable plugins, and this can be done via command-line
flags such as C<--exclude-plugin=RSS,Verbose>.

=cut

=head1 THEMES

There is a small collection of themes bundled with the release, and it
is assumed you might write your own.

Themes are located beneath a particular directory, such that the files contained
in one are located at:

=for example begin

      $theme-dir/$theme-name

=for example end

These two names can be set via C<--theme-dir> and C<--theme> respectively.

Each theme will consist of a small number of template files that use
L<HTML::Template> by default but can use other templating systems depending on
the C<--template-engine> parameter. In brief, a theme is complete if it contains:

=over 8

=item C<archive.tmpl>

This is the file used to generate an archived month/year index.

=item C<archive_index.tmpl>

This is the file used to generate the top-level C</archive/> page.

=item C<entry.tmpl>

This is the file used to generate each individual blog-entry.

=item C<index.tmpl>

This is the file used to generate your front-page.

=item C<index.rss>

This is the file used to generate your RSS feed.

=item C<tag.tmpl>

This is the file used to generate the top-level C</tag/XX/> page.

=item C<tag_index.tmpl>

This is the file used to generate the top-level C</tag/> page.

=back

Each theme page will receive different data, as set by the appropriate
generation plugin, and any global C<Chronicle::Plugin::Snippets> plugins
which have been loaded.

=head2 TEMPLATE ENGINES

There are currently three possible values for the C<--template-engine>
parameter. Each uses templates with different file extensions so you can have
different sets of templates in the same directory without causing conflicts.

=over 8

=item HTMLTemplate

This is the traditional template engine and the default. Templates are named
C<*.tmpl>.

=item Xslate

Uses L<Text::Xslate>, an extremely fast and versatile templating system, with
its default "Kolon" syntax. Templates are named C<*.tx>.

=item HTMLTemplate

Uses L<Text::Xslate> with the Template Toolkit compatible "TTerse" syntax.
Templates are named C<*.ttx>.

=back

=cut

=head1 FAQ

=over 8

=item How do I generate a new type of pages?

If you wish to generate a new hierarchy of pages then you should create
a new plugin to generate them.  The C<Chronicle::Plugin::Generate::RSS>
would be a good starting point.

=item How do I include some data in each page?

If you wish to make a piece of data available to B<all> output pages
then it must be generated first.

That is what the plugins beneath the C<Chronicle::Plugin::Snippets> hierarchy
do - They are invoked first and can update the global variables to make some
new data available to all the templates.

This is how the global tag-cloud, recent posts, and similar data is able
to be included in the sidebar in the default template.

=item How do I generate non-English dates?

Chronicle uses the L<Date::Language> module for generating localized dates.
For each of the variables "date", "time" and "date_short" there exists a
corresponding variable with the suffix "_loc" ("date_loc" etc.) that
contains the localized version.

For historical reasons, the language is configured via the environment variable
C<$MONTHS>. For example, to get Finnish dates, you could use:

=for example begin

    MONTHS=Finnish chronicle ...

=for example end

=item How do I ignore draft-posts?

If you add the header C<draft: 1> to your pending post then it will
be excluded from the blog, via the L<Chronicle::Plugin::SkipDrafts>
plugin.

=item How do I schedule future-posts?

If you add a C<publish:> header, rather than a C<date:> header, to your
posts it will allow you to schedule the release of future posts via the
L<Chronicle::Plugin::PostSpooler> plugin.

=back

=cut

=head1 LICENSE

This module is free software; you can redistribute it and/or modify it
under the terms of either:

a) the GNU General Public License as published by the Free Software
Foundation; either version 2, or (at your option) any later version,
or

b) the Perl "Artistic License".

=cut

=head1 AUTHOR

Steve Kemp <steve@steve.org.uk>

=cut


use strict;
use warnings;
use open ':std' => ':locale';
use open IO     => ':encoding(UTF-8)';

package Chronicle;
use Module::Pluggable::Ordered require => 1, inner => 0;

our $VERSION = "5.1.8";

use DBI;
use Date::Format;
use Date::Parse;
use Digest::MD5 qw(md5_hex);
use File::Basename;
use File::Find;
use File::Path;
use File::ShareDir;
use Getopt::Long;
use HTML::Element;
use Pod::Usage;

use Chronicle::Utils qw/ format_datetime /;
use Chronicle::URI;
use Chronicle::Config::Reader;
use Chronicle::Template;

#
#  Default options - These may be overridden by the command-line
# or via the configuration files:
#
#   /etc/chronicle/config
#   ~/.chronicle/config
#
#  NOTE: These filenames were deliberately chosen to avoid clashing
# with previous releases of chronicle.
#
our %CONFIG;
$CONFIG{ 'input' }        = "./data";
$CONFIG{ 'pattern' }      = "*.txt";
$CONFIG{ 'output' }       = "./output";
$CONFIG{ 'database' }     = "./blog.db";
$CONFIG{ 'comment-days' } = 10;
$CONFIG{ 'entry-count' }  = 10;
$CONFIG{ 'rss-count' }    = 10;

$CONFIG{ 'theme-dir' } = File::ShareDir::dist_dir('App-Chronicle');
$CONFIG{ 'theme' }     = "default";
$CONFIG{ 'unicode' }   = "no";
$CONFIG{ 'verbose' }   = 0;
$CONFIG{ 'top' }       = "/";
$CONFIG{ 'exclude-plugins' } =
  "Chronicle::Plugin::Archived,Chronicle::Plugin::Verbose";
$CONFIG{ 'template-engine' } = "HTMLTemplate";

our %DATABASE_SCHEMA = (
    blog => {
        columns =>
          [qw/ id file date title link mtime body truncatedbody template /],
        create => [
            'CREATE TABLE blog (id INTEGER PRIMARY KEY,file,date,title,link,mtime,body,truncatedbody,template )',
            'CREATE UNIQUE INDEX unique_title on blog (title)',
        ],
    },
    tags => { columns => [qw/ id name blog_id /],
              create =>
                ['CREATE TABLE tags (id INTEGER PRIMARY KEY, name, blog_id )'],
            },
    pages => {
        columns => [qw/ id filename title content template /],
        create => [],    # Chronicle::Plugin::StaticPages will do it
             },
);

#
#  Options here are passed to all templates
#
our %GLOBAL_TEMPLATE_VARS = ();


#
#  Read the global and per-user configuration file, if present.
#
my $cnf = Chronicle::Config::Reader->new();
$cnf->parseFile( \%CONFIG, "/etc/chronicle/config" );
$cnf->parseFile( \%CONFIG, $ENV{ 'HOME' } . "/.chronicle/config" );


#
#  Parse our command-line options
#
parseCommandLine();


#
#  If we have a configuration file then read it.
#
$cnf->parseFile( \%CONFIG, $CONFIG{ 'config' } )
  if ( defined $CONFIG{ 'config' } );

#
# Switch on Mac quirks for Unicode file names if required
#
Chronicle::URI::i_use_hfs if $CONFIG{ 'unicode' } eq 'mac';

#
# If Unicode is on, also keep HTML::Element from encoding non-ASCII as entities
#
if ( $CONFIG{ 'unicode' } ne 'no' )
{
    $HTML::Element::encoded_content = 1;
}

#
# Get the database handle, creating the database on-disk if necessary.
#
my $dbh = getDatabase();


#
#  Parse/update blog posts from our input directory.
#
updateDatabase($dbh);


#
#  Ensure we have an output directory.
#
File::Path::make_path( $CONFIG{ 'output' },
                       {  verbose => 0,
                          mode    => oct("755"),
                       } )
  unless ( -d $CONFIG{ 'output' } );



#
#  Call on_initiate for all plugins which have not been excluded.
#
foreach my $plugin ( get_plugins_for_method("on_initiate") )
{
    $CONFIG{ 'verbose' } && print "Calling $plugin on_initiate()\n";
    $plugin->on_initiate( config => \%CONFIG, dbh => $dbh );
}


#
#  Call on_generate for all plugins which have not been excluded.
#
#  `on_generate` is logically identical to `on_initiate`, except
# the former plugins are guaranteed to have been invoked first.
#
foreach my $plugin ( get_plugins_for_method("on_generate") )
{
    $CONFIG{ 'verbose' } && print "Calling $plugin on_generate()\n";
    $plugin->on_generate( config => \%CONFIG, dbh => $dbh );
}


#
#  Copy any static content from the theme-directory.
#
my $ts = $CONFIG{ 'theme-dir' } . "/" . $CONFIG{ 'theme' } . "/static";
if ( -d $ts )
{

    #
    #  This could be improved, but it will cope with subdirectories, etc,
    # so for the moment it will remain.
    #
    system("/bin/tar -C $ts -cpf - . | /bin/tar -C $CONFIG{'output'} -xf -");
}


#
#  Now we're done.
#
$dbh->disconnect();
exit(0);




=begin doc

Read each blog-post from beneath ./data/ - and if it is missing from the
database then insert it.

We also handle the case where the file on disk is newer than the database
version - in that case we remove the database version and update it to
contain the newer content.

=end doc

=cut

sub updateDatabase
{
    my ($dbh) = (@_);

    #
    #  Assume each entry is already present in the database.
    #
    my $sql =
      $dbh->prepare("SELECT id FROM blog WHERE ( file=? AND mtime=? )") or
      die "Failed to select post";


    #
    #  Look for posts.
    #

    foreach
      my $file ( get_post_files( $CONFIG{ 'input' }, $CONFIG{ 'pattern' } ) )
    {

        #
        # We want to find the mtime to see if it is newer than the DB-version.
        #
        my ( $dev,   $ino,     $mode, $nlink, $uid,
             $gid,   $rdev,    $size, $atime, $mtime,
             $ctime, $blksize, $blocks
           )
          = stat($file);


        #
        #  Lookup the existing entry
        #
        $sql->execute( $file, $mtime ) or
          die "Failed to execute: " . $dbh->errstr();
        my $result = $sql->fetchrow_hashref();

        if ( !$result )
        {

            #
            #  The file is not in the database, or it is present with a
            # different modification time.
            #
            #  Parse the file and insert it.
            #
            insertPost( $dbh, $file, $mtime );
        }
    }

    $sql->finish();
}



=begin doc

Given a filename containing a blog post then insert that post into
the database.

We also update the tags.

=end doc

=cut

sub insertPost
{
    my ( $dbh, $filename, $mtime ) = (@_);

    $CONFIG{ 'verbose' } && print "Adding post to DB: $filename\n";

    #
    #  Is the entry present, but with a different mtime?
    #
    #  If so we need to delete the post, and the tags which are pointing
    # at it, otherwise we'll have orphaned tags.
    #
    my $sql = $dbh->prepare("SELECT id FROM blog WHERE file=?");
    $sql->execute($filename) or die "Failed to execute :" . $dbh->errstr();
    my $id;
    $sql->bind_columns( undef, \$id );

    while ( $sql->fetch() )
    {
        $CONFIG{ 'verbose' } && print "Replacing DB post: $id\n";

        #
        #  Delete the tags referring to this old post.
        #
        my $del_tags = $dbh->prepare("DELETE FROM tags WHERE blog_id=?") or
          die "Failed to prepare ";
        $del_tags->execute($id) or
          die "Failed to delete tags:" . $dbh->errstr();
        $del_tags->finish();

        #
        #  Now delete the entry
        #
        my $del_blog = $dbh->prepare("DELETE FROM blog WHERE id=?") or
          die "Failed to prepare ";
        $del_blog->execute($id) or
          die "Failed to delete blog:" . $dbh->errstr();
        $del_blog->finish();
    }
    $sql->finish();


    #
    #  Read the actual entry from disk.
    #
    my $inHeader = 1;
    open my $handle, "<:encoding(utf-8)", $filename or
      die "Failed to read $filename $!";

    #
    #  The meta-data which comes from the posts header.
    #
    my %meta;

    while ( my $line = <$handle> )
    {
        if ( $inHeader > 0 )
        {

            #
            #  If the line has the form of "key: value"
            #
            if ( $line =~ /^([^:]+):(.*)/ )
            {
                my $key = $1;
                my $val = $2;

                $key = lc($key);
                $key =~ s/^\s+|\s+$//g;
                $val =~ s/^\s+|\s+$//g;

                #
                #  "subject" is a synonym for "title".
                #
                $key = "title" if ( $key eq "subject" );

                #
                #  Update the value if there is one present,
                # and we've not already saved that one away.
                #
                $meta{ $key } = $val
                  if ( defined($val) && length($val) && !$meta{ $key } );

            }
            else
            {

                #
                #  Empty line == end of header
                #
                $inHeader = 0 if ( $line =~ /^$/ );
            }
        }
        else
        {
            $meta{ 'body' } .= $line;
        }
    }
    close($handle);


    # Ensure we have a title.
    defined $meta{ 'title' } or die "Missing `Title:' line in `$filename'";

    # initiate the truncated body.
    $meta{ 'truncatedbody' } = '';

    # initiate the template and change if there is a template is supplied.
    $meta{ 'template' } = "entry.tmpl" unless defined $meta{ 'template' };

    #
    #  Generate the link from the title of the post.
    #
    my $suffix = $CONFIG{ 'entry_suffix' } // ".html";
    my $link = $meta{ 'title' };
    if ( $CONFIG{ 'unicode' } eq 'no' )
    {
        # Unicode off, only use 7-bit alphanumerics from titles
        $link =~ s/[^a-zA-Z0-9]/_/gi;
    }
    else
    {
        # Allow everything alphanumeric in any Unicode block
        $link =~ s/[^[:alnum:]]/_/gi;
    }
    $meta{ 'link' } = Chronicle::URI->new( $link . $suffix );

    #
    #  Let any plugins have access to the filename.
    #
    $meta{ 'file' } = $filename;

    #
    #  Are we going to skip this post?
    #
    my $skip = 0;

    #
    #  Update our meta-data via any loaded plugins.
    #
    foreach my $plugin ( get_plugins_for_method("on_insert") )
    {
        $CONFIG{ 'verbose' } && print "Calling $plugin - on_insert\n";
        my $m = $plugin->on_insert( config => \%CONFIG,
                                    dbh    => $dbh,
                                    data   => \%meta
                                  );

        #print "after $plugin: $meta{ 'body' }\n";
        if ( !$m )
        {

            #
            #  We'll skip any post if the insert plugin returned an
            # empty value.
            #
            $skip = 1;
        }
        else
        {

            #
            #  If we know we're going to skip this post then we'll
            # not update the meta-data, which will ensure that
            # future plugins won't have empty data-structures.
            #
            #  This isn't essential but it helps avoid warnings or
            # weirdness.
            #
            %meta = %$m;
        }
    }


    if ($skip)
    {
        $CONFIG{ 'verbose' } && print "Skipping post: $filename\n";
        return;
    }


    #
    #  Convert the date to a seconds past epoch.
    #
    if ( !$meta{ 'date' } )
    {
        die "Post is missing a date header - $filename\n";
    }
    else
    {
        my $d = str2time( $meta{ 'date' } );
        die "Failed to parse $meta{'date'} into a time, from $filename"
          unless $d;

        $meta{ 'date' } = $d;
    }

    #
    #  Now insert
    #
    my $post_add = $dbh->prepare(
        "INSERT INTO blog (file,date,title,link,mtime,body,truncatedbody,template) VALUES( ?,?,?,?,?,?,?,?)"
      ) or
      die "Failed to prepare";

    $post_add->execute( $filename,
                        $meta{ 'date' },
                        $meta{ 'title' },
                        $meta{ 'link' },
                        $mtime,
                        $meta{ 'body' },
                        $meta{ 'truncatedbody' },
                        $meta{ 'template' }
      ) or
      die "Failed to insert:" . $dbh->errstr();

    my $blog_id = $dbh->func('last_insert_rowid');


    #
    #  Add any tags the post might contain.
    #
    if ( $meta{ 'tags' } )
    {
        my $tag_add =
          $dbh->prepare("INSERT INTO tags (blog_id, name) VALUES( ?,?)") or
          die "Failed to prepare";

        foreach my $tag ( split( /,/, $meta{ 'tags' } ) )
        {

            # strip leading and trailing space.
            $tag =~ s/^\s+//;
            $tag =~ s/\s+$//;

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

            # Tags are always down-cased
            $tag = lc($tag);

            #
            #  Add the new tag to the post.
            #
            $tag_add->execute( $blog_id, $tag ) or
              die "Failed to execute:" . $dbh->errstr();
        }
    }
}


=begin doc

Given a database handle, check that all required tables and columns exist.
Returns a boolean indicating success.

=end doc

=cut

sub check_database_structure
{
    my ($dbh) = (@_);
    while ( my ( $table_name, $table_spec ) = each %DATABASE_SCHEMA )
    {
        for my $column ( @{ $table_spec->{ columns } } )
        {
            local $dbh->{ PrintError } = 0;
            $dbh->selectcol_arrayref("SELECT $column FROM $table_name LIMIT 1")
              or
              return;
        }
    }
    return 1;
}

=begin doc

Open a named file as an SQLite database

=end doc

=cut

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

    my $dbh =
      DBI->connect( "dbi:SQLite:dbname=$filename", "", "",
                    { AutoCommit => 1, RaiseError => 0 } ) or
      die "Could not open SQLite database: $DBI::errstr";
    $dbh->{ sqlite_unicode } = 1;
    return $dbh;
}

=begin doc

Create a database handle, if necessary creating the tables first.

=end doc

=cut

sub getDatabase
{

    #
    #  Is the database already present?
    #
    my $present = 0;

    #
    #  Ensure we have something specified.
    #
    die "No database configured - please use --database=/path/tocreate"
      unless ( $CONFIG{ 'database' } );

    #
    #  Does it exist?
    #
    $present = 1 if ( -e $CONFIG{ 'database' } );

    my $dbh = get_database_handle( $CONFIG{ 'database' } );

    # If it exists but fails the structure check, just delete it
    if ( $present and not check_database_structure($dbh) )
    {
        $dbh->disconnect;
        unlink $CONFIG{ 'database' };
        $dbh     = get_database_handle( $CONFIG{ 'database' } );
        $present = 0;
    }

    if ( !$present )
    {
        for my $table_spec ( values %DATABASE_SCHEMA )
        {
            $dbh->do($_) for @{ $table_spec->{ create } };
        }

        foreach my $plugin ( get_plugins_for_method("on_db_create") )
        {
            $CONFIG{ 'verbose' } && print "Calling $plugin - on_db_create\n";
            $plugin->on_db_create( config => \%CONFIG,
                                   dbh    => $dbh, );
        }

    }


    foreach my $plugin ( get_plugins_for_method("on_db_load") )
    {
        $CONFIG{ 'verbose' } && print "Calling $plugin - on_db_load\n";
        $plugin->on_db_load( config => \%CONFIG,
                             dbh    => $dbh, );
    }

    return ($dbh);
}



=begin doc

Fetch the blog post with the given ID

=end doc

=cut

sub getBlog
{
    my (%params) = (@_);

    #
    #  These are compulsary
    #
    my $dbh = $params{ 'dbh' } || die "Missing database handle";
    my $id  = $params{ 'id' }  || die "Missing ID";

    #
    #  This is optional, and present so that the date/time format
    # may be changed by the user in their configuration file.
    #
    my $config = $params{ 'config' } || undef;


    #
    #  Get the blog-post
    #
    my $sql = $dbh->prepare("SELECT * FROM blog WHERE id=?") or
      die "Failed to prepare: " . $dbh->errstr();
    $sql->execute($id) or
      die "Failed to execute:" . $dbh->errstr();
    my $data = $sql->fetchrow_hashref();
    $sql->finish();

    #
    #  Get the tags, if any
    #
    $sql =
      $dbh->prepare("SELECT name FROM tags WHERE blog_id=? ORDER by name ASC")
      or
      die "Failed to prepare: " . $dbh->errstr();
    $sql->execute($id);

    my ( $tag, $tags );
    $sql->bind_columns( undef, \$tag );

    while ( $sql->fetch() )
    {
        push( @$tags, { tag => $tag } );
    }
    $sql->finish();

    # Save the tags, if we found some
    $data->{ 'tags' } = $tags if ( $tags && @$tags );

    # Create an URI object for the link
    $data->{ 'link' } = Chronicle::URI->new( $data->{ 'link' } );

    #
    #  Generate the date/time from mtime;
    #
    #  If the date is set then we use it, and get the time from the mtime
    #
    #  If the date is not set then we use the mtime for both date & time.
    #
    my $posted =
      ( $data->{ 'posted' } = $data->{ 'date' } ) || $data->{ 'mtime' };
    my $time = $posted;


    ##
    ##  Format dates and times
    ##
    @$data{ qw/ time time_loc / } =
      format_datetime( $config, 'time_format', '%X', $time );
    @$data{ qw/ date date_loc / } =
      format_datetime( $config, 'date_format', '%a, %e %b %Y', $time );
    @$data{ qw/ date_short date_short_loc / } =
      format_datetime( $config, 'short_date_format', '%e %B %Y', $time );

    #
    # For the RSS-Feed.
    #
    # This is a W3C Datetime (https://www.w3.org/TR/NOTE-datetime)
    # which is more specific than ISO-8601 in allowing only colon-separated
    # hours/minutes in a time zone
    #
    $data->{ 'iso_8601' } = time2str( '%Y-%m-%dT%T%z', $time );
    $data->{ 'iso_8601' } =~ s/(..)$/:$1/;

    #
    #  If comments are enabled then populate the blog-post with them too.
    #
    if ( $CONFIG{ 'comments' } )
    {
        my $comments = getComments( $data->{ 'link' } );
        if ($comments)
        {
            $data->{ 'comments' } = $comments;

            my $count = scalar(@$comments);
            $data->{ 'comment_count' }  = $count;
            $data->{ 'comment_plural' } = 1
              if ( ( $count == 0 ) || ( $count > 1 ) );
        }

        #
        #  Comments are enabled at this point.
        #
        #  If the post is not "too old" then allow the theme-templates
        # to know that.
        #
        my $now = time;
        my $ago = $now - $posted;
        my $age = ( ( 60 * 60 * 24 ) * ( $CONFIG{ 'comment-days' } ) );

        if ( $ago < $age )
        {
            $data->{ 'comments_enabled' } = 1;
        }
        else
        {
            $data->{ 'comments_enabled' } = undef;
        }
    }

    return ($data);
}



=begin doc

Get the comments associated with a given post, if comments are
enabled and there are some present.

The way this works is constrained by legacy concerns.  Assuming
we have a blog which has two entries:

=for example begin

   data/this_is_my_first.txt
   data/i_like_cakes.txt

=for example end

Then we're looking for comments by searching the comment-directory
for files of the form:

=for example begin

    comments/this_is_my_first.html.13-March-2017-04:25:29
    comments/this_is_my_first.html.13-March-2017-03:21:32
    ..

=for example end

Here we have "id", which is based upon the filename of the B<input>
blog-entry, the C<.html.> suffix, and then the date/time.

As you can see we pretty much find comments by running a glob to find
comment-files, then sorting by mtime, before finally looking to see
if we have a match of comments for this particular entry.

We could be far cleaner if we just used:

=for example begin

   comments/$ID/$CTIME

=for example end

Which might result in files like this:

=for example begin

   ..
   comments/this_is_my_first/1489381208
   comments/this_is_my_first/1489381228
   comments/i_like_cakes/1429381228
   ..

=for example end

The downside of this approach is that the comment-writing CGI-script
would need to run C<mkdir> and we might be more at risk of directory
traversal, and other badness.  Also the seconds-past-epoch might result
in a collision - but we have that risk already, ssh!

=end doc

=cut

sub getComments
{
    my ($title) = (@_);

    #
    #  If there is no comment-directory setup then return nothing.
    #
    return unless ( $CONFIG{ 'comments' } );

    #
    #  If there is a comment-directory setup, but it doesn't exist
    # then again we do nothing.
    #
    return unless ( -d $CONFIG{ 'comments' } );


    #
    #  The resulting comments for this piece.
    #
    my $results;

    #
    #  Strip the .html suffix from the filename
    #
    if ( $title =~ /^(.*)\.([^.]+)$/ )
    {
        $title = $1;
    }

    #
    #  Lower-case it.
    #
    $title = lc($title);


    #
    #  Find each comment file.
    #
    my @entries;
    foreach my $file ( glob( $CONFIG{ 'comments' } . "/" . $title . "*" ) )
    {
        push( @entries, $file );
    }

    #
    # Sort them into order by mtime.
    #
    @entries = sort {( stat($a) )[9] <=> ( stat($b) )[9]} @entries;

    #
    #  Now process them, extracting the submitters IP, email,
    # etc, etc, from the body of the comment-file.
    #
    foreach my $file (@entries)
    {
        my $date    = "";
        my $name    = "";
        my $link    = "";
        my $body    = "";
        my $mail    = "";
        my $pubdate = "";

        #
        #  The name of the file has the date/time in it.
        #
        #  This could be so much cleaner, but it'd break if I changed
        # anything.
        #
        if ( $file =~ /^(.*)\.([^.]+)$/ )
        {
            $date = $2;

            if ( $date =~ /(.*)-([0-9:]+)/ )
            {
                my $d = $1;
                my $t = $2;

                $d =~ s/-/ /g;

                $date = "Submitted at $t on $d";
            }
        }

        #
        #  Process the contents of the file.
        #
        open my $comment, "<:encoding(utf-8)", $file or
          next;

        foreach my $line (<$comment>)
        {
            # Skip empty lines.
            next if ( !defined($line) );
            chomp($line);

            # Skip fields we don't care about.
            next if ( $line =~ /^IP-Address:/ );
            next if ( $line =~ /^User-Agent:/ );

            if ( !length($name) && $line =~ /^Name: (.*)/i )
            {
                $name = $1;
            }
            elsif ( !length($mail) && $line =~ /^Mail: (.*)/i )
            {
                $mail = $1;
            }
            elsif ( !length($link) && $line =~ /^Link: (.*)/i )
            {
                #
                # Only save the link if it is fully-qualified.
                #
                my $match = $1;

                $link = $match if ( $match =~ /^https?:/i );
            }
            else
            {
                $body .= $line . "\n";
            }
        }
        close($comment);

        if ( length($name) &&
             length($mail) &&
             length($body) )
        {

            #
            #  Add a gravitar link to the comment in case the
            # theme wishes to use it.
            #
            my $default  = "";
            my $size     = 32;
            my $gravitar = "//www.gravatar.com/avatar.php?gravatar_id=" .
              md5_hex( lc $mail ) . ";size=" . $size;

            #
            # A comment which was submitted by the blog author might
            # have special theming.
            #
            my $author = 0;

            #
            # We'll look for matches in the author-setup
            #
            if ( $CONFIG{ 'author' } )
            {
                #
                #  This is comma-separated
                #
                foreach my $ent ( split( /,/, lc( $CONFIG{ 'author' } ) ) )
                {
                    # strip leading and trailing space.
                    $ent =~ s/^\s+//;
                    $ent =~ s/\s+$//;

                    $author = 1 if ( lc($mail) eq $ent );
                }
            }

            #
            # Store the comment
            #
            push( @$results,
                  {  name     => $name,
                     author   => $author,
                     gravitar => $gravitar,
                     link     => $link,
                     mail     => $mail,
                     body     => $body,
                     date     => $date,
                  } );

        }
        else
        {
            $CONFIG{ 'verbose' } &&
              print
              "I didn't like length of \$name ($name), \$mail ($mail) or \$body ($body)\n";
        }
    }

    return ($results);
}



=begin doc

Load a L<Chronicle::Template> object, either by reference to the filename
(which is unqualified and assumed to be located beneath the given
theme-directory), or via a scalar reference containig a template as a string.

Some of the plugins distributed with Chronicle will contain embedded
C<HTML::Template> snippets in their C<DATA> sections.  These include
L<Chronicle::Plugin::Generate::RSS> and L<Chronicle::Plugin::Generate::Sitemap>.

=end doc

=cut

sub load_template
{
    my ( $filename, $scalar ) = (@_);

    my %options = ( type      => $CONFIG{ 'template-engine' },
                    theme_dir => $CONFIG{ 'theme-dir' },
                    theme     => $CONFIG{ 'theme' },
                  );

    my $tmpl =
      Chronicle::Template->create(
            $filename ? ( tmpl_file => $filename ) : ( tmpl_string => $scalar ),
            %options ) or
      return;    # simply return undef to let the caller retry

    for my $opt (qw/ blog_title blog_subtitle /)
    {
        $tmpl->param( $opt => $CONFIG{ $opt } ) if defined $CONFIG{ $opt };
    }

    $tmpl->param( \%GLOBAL_TEMPLATE_VARS );
    return $tmpl;
}

=begin doc

Parse the command-line options.

=end doc

=cut

sub parseCommandLine
{
    my $HELP   = 0;
    my $MANUAL = 0;

    #
    #  Parse options.
    #
    if (
        !GetOptions(

            # Help options
            "help",    \$HELP,
            "manual",  \$MANUAL,
            "verbose", \$CONFIG{ 'verbose' },
            "version", \$CONFIG{ 'version' },

            # theme support
            "theme=s",           \$CONFIG{ 'theme' },
            "theme-dir=s",       \$CONFIG{ 'theme-dir' },
            "list-themes",       \$CONFIG{ 'list-themes' },
            "template-engine=s", \$CONFIG{ 'template-engine' },

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

            # limits
            "entry-count=s", \$CONFIG{ 'entry-count' },
            "rss-count=s",   \$CONFIG{ 'rss-count' },

            # optional
            "config=s",       \$CONFIG{ 'config' },
            "database=s",     \$CONFIG{ 'database' },
            "author=s",       \$CONFIG{ 'author' },
            "comment-days=s", \$CONFIG{ 'comment-days' },
            "force",          \$CONFIG{ 'force' },
            "unicode=s",      \$CONFIG{ 'unicode' },
            "lower-case",     \$CONFIG{ 'lower-case' },

            # plugins
            "list-plugins",      \$CONFIG{ 'list-plugins' },
            "exclude-plugins=s", \$CONFIG{ 'exclude-plugins' },

            # title
            "blog-title=s",    \$CONFIG{ 'blog_title' },
            "blog-subtitle=s", \$CONFIG{ 'blog_subtitle' },

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

        ) )
    {
        exit;
    }

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

    #
    #  Show our version number, and terminate.
    #
    if ( $CONFIG{ 'version' } )
    {
        print "Chronicle $VERSION\n";
        exit(0);
    }

    #
    #  List themes.
    #
    if ( $CONFIG{ 'list-themes' } )
    {

        #
        #  Global themese
        #
        my $global = File::ShareDir::dist_dir('App-Chronicle');

        #
        #  The theme-directories we'll inspect
        #
        my @dirs = ();
        push( @dirs, $global );
        if ( $CONFIG{ 'theme-dir' } && ( $CONFIG{ 'theme-dir' } ne $global ) )
        {
            push( @dirs, $CONFIG{ 'theme-dir' } );
        }

        #
        #  For each global/local directory show the contents.
        #
        foreach my $dir (@dirs)
        {
            print "Themes beneath $dir\n";

            foreach my $ent ( glob( $dir . "/*" ) )
            {
                my $name = File::Basename::basename($ent);
                print "\t" . $name . "\n" if ( -d $ent );
            }
        }
        exit(0);
    }

    #
    #  List plugins
    #
    if ( $CONFIG{ 'list-plugins' } )
    {
        for my $plugin ( Chronicle->plugins_ordered() )
        {
            print $plugin . "\n";

            if ( $CONFIG{ 'verbose' } )
            {
                foreach my $method (
                    sort
                    qw! on_db_create on_db_load on_insert on_initiate on_generate  !
                  )
                {
                    if ( $plugin->can($method) )
                    {
                        print "\t$method\n";
                    }
                }
            }
        }
        exit 0;
    }

    # Show an error if 'unicode' is not an allowed value
    $CONFIG{ 'unicode' } = lc $CONFIG{ 'unicode' };
    unless ( grep {$_ eq $CONFIG{ 'unicode' }} qw/ no yes mac / )
    {
        print STDERR "--unicode must be one of `yes', `no' or `mac'";
        exit 1;
    }
}



=begin doc

Return an array of plugins that implement the given method.

This result set will exclude anything that has been deliberately
excluded by the user.

=end doc

=cut

sub get_plugins_for_method
{
    my ($method) = (@_);

    my @plugins = ();

    #
    #  Call any on_initiate plugins we might have loaded.
    #
    for my $plugin ( Chronicle->plugins_ordered() )
    {
        my $skip = 0;

        if ( $CONFIG{ 'exclude-plugins' } )
        {
            foreach my $exclude ( split( /,/, $CONFIG{ 'exclude-plugins' } ) )
            {

                # strip leading and trailing space.
                $exclude =~ s/^\s+//;
                $exclude =~ s/\s+$//;

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

                if ( $plugin =~ /\Q$exclude\E/i )
                {
                    $CONFIG{ 'verbose' } && print "Skipping plugin: $plugin\n";
                    $skip = 1;
                }
            }
        }

        next if ($skip);
        next unless $plugin->can($method);

        push( @plugins, $plugin );
    }

    return (@plugins);
}


=begin doc

Return an array of entires from a given folder with a given suffix

=end doc

=cut

sub get_post_files
{

    my ( $dir, $suffix ) = @_;

    #  This allows for backward compatility for the '*.txt' for glob'ing
    # blog entries
    $suffix =~ s/\*\.//;

    my @text_files;
    my $tf_finder = sub {
        return if !-f;
        return if !/\.\Q$suffix\E\z/;
        push @text_files, $File::Find::name;
    };
    find( $tf_finder, $dir );
    return @text_files;
}