#!/usr/bin/env perl BEGIN { use FindBin; use lib "$FindBin::Bin/mojo/lib" } use Mojolicious::Lite; use Mojo::Date; use Mojo::Template; use Mojo::ByteStream; use Mojo::Loader; use Mojo::JSON; use Mojo::Command; use Mojo::ByteStream 'b'; use Pod::Simple::HTML; require File::Basename; $ENV{LANG} = 'C'; require Time::Piece; require Time::Local; our $VERSION = '0.900101'; my %config = ( perl5lib => '', loglevel => 'debug', server => 'cgi', author => $ENV{BOOTYLICIOUS_AUTHOR} || 'whoami', email => $ENV{BOOTYLICIOUS_EMAIL} || '', title => $ENV{BOOTYLICIOUS_TITLE} || 'Just another blog', about => $ENV{BOOTYLICIOUS_ABOUT} || 'Perl hacker', descr => $ENV{BOOTYLICIOUS_DESCR} || 'I do not know if I need this', articlesdir => $ENV{BOOTYLICIOUS_ARTICLESDIR} || 'articles', pagesdir => $ENV{BOOTYLICIOUS_PAGESDIR} || 'pages', draftsdir => $ENV{BOOTYLICIOUS_DRAFTSDIR} || 'drafts', publicdir => $ENV{BOOTYLICIOUS_PUBLICDIR} || 'public', templatesdir => $ENV{BOOTYLICIOUS_TEMPLATESDIR} || undef, # defaults to 'templates' footer => $ENV{BOOTYLICIOUS_FOOTER} || 'Powered by Bootylicious', menu => [ index => '/index.html', tags => '/tags.html', archive => '/archive.html' ], theme => '', cuttag => '[cut]', cuttext => 'Keep reading', pagelimit => 10, meta => [], css => [], js => [], datefmt => '%a, %d %b %Y', strings => { 'archive' => 'Archive', 'archive-description' => 'Articles index', 'tags' => 'Tags', 'tags-description' => 'Tags overview', 'tag' => 'Tag', 'tag-description' => 'Articles with tag [_1]', 'draft' => 'Draft', 'permalink-to' => 'Permalink to', 'not-found' => 'The page you are looking for was not found', 'error' => 'Internal error occuried :(' }, template_handler => 'ep' ); my %hooks = ( preinit => [], init => [ ], prepare => [ sub { my $c = shift; foreach my $init (qw/title description/) { $c->stash($init => '') unless $c->stash($init); } } ], finalize => [] ); if ($ARGV[0] && $ARGV[0] eq 'inflate') { my $command = Mojo::Command->new; $command->create_rel_dir('templates'); foreach my $template ( qw|index.html archive.html index.rss tags.html tag.html article.html page.html draft.html layouts/wrapper.html | ) { my $data = $command->get_data("$template.ep", 'main'); $command->write_rel_file("templates/$template.ep", $data); } foreach my $dir (qw/articlesdir draftsdir pagesdir publicdir/) { $command->create_rel_dir(config($dir)); } exit(0); } app->home->parse($ENV{BOOTYLICIOUS_HOME}) if $ENV{BOOTYLICIOUS_HOME}; _read_config_from_file(app->home->rel_file('bootylicious.conf')); app->log->level($config{loglevel}); app->renderer->default_handler(config('template_handler')); app->renderer->add_helper(stash => sub { my $c = shift; $c->stash(@_); }); app->renderer->add_helper(param => sub { my $c = shift; $c->req->param(@_)}); app->renderer->add_helper(url => \&url); app->renderer->add_helper(url_abs => \&url_abs); app->renderer->add_helper(config => sub { shift; config(@_) }); app->renderer->add_helper(date => \&date); app->renderer->add_helper(date_rss => \&date_rss); app->renderer->add_helper( strings => sub { my $c = shift; my $string = config('strings')->{$_[0]}; for (my $i = 0; $i < @_; $i++) { $string =~ s/\[_$i\]/$_[$i]/; } return $string; } ); _load_plugins($config{plugins}); _call_hook(app, 'preinit'); sub config { if (@_) { return $config{$_[0]} if @_ == 1; %config = (%config, @_); } return \%config; } ladder sub { my $self = shift; return 1 if $self->stash->{format}; return 1 unless $self->req->url; return 1 if $self->req->url =~ m{/$}; my $canonical_location = $self->req->url->to_abs . '.html'; $self->app->log->debug("Path is not canonical: " . $self->req->url); $self->app->log->debug("Redirecting to: " . $canonical_location); $self->redirect_to($canonical_location); return 0; }; sub index { my $c = shift; my $timestamp = $c->req->param('timestamp') || 0; my $article = {}; my ($articles, $pager) = get_articles(limit => $config{pagelimit}, timestamp => $timestamp); my $last_created = time; my $last_modified = time; if (@$articles) { $article = $articles->[0]; $last_created = $articles->[0]->{created}; $last_modified = $article->{modified}; return 1 unless _is_modified($c, $last_modified); } my $later = 0; $c->stash( article => $article, articles => $articles, pager => $pager ); $c->res->headers->header('Last-Modified' => Mojo::Date->new($last_modified)); $c->stash(template => 'index'); if ($c->stash('format') && $c->stash('format') eq 'rss') { $c->stash( last_created => $last_created, last_modified => $last_modified, ); } else { $c->stash(layout => 'wrapper', title => ''); } _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } get '/' => \&index => 'root'; get '/index' => \&index => 'index'; get '/archive' => sub { my $c = shift; my $root = $c->app->home; my $last_modified = Mojo::Date->new; my ($articles) = get_articles(limit => 0); if (@$articles) { $last_modified = $articles->[0]->{modified}; return 1 unless _is_modified($c, $last_modified); } $c->res->headers->header('Last-Modified' => $last_modified); $c->stash( layout => 'wrapper', articles => $articles, last_modified => $last_modified, ); _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } => 'archive'; get '/tags/:tag' => sub { my $c = shift; my $tag = $c->stash('tag'); my ($articles) = get_articles(limit => 0); $articles = [ grep { grep {m/^\Q$tag\E$/} @{$_->{tags}} } @$articles ]; unless (@$articles) { $c->stash(rendered => 1); $c->app->static->serve_404($c); return 1; } my $last_modified = $articles->[0]->{modified}; return 1 unless _is_modified($c, $last_modified); $c->res->headers->header('Last-Modified' => Mojo::Date->new($last_modified)); my $last_created = $articles->[0]->{created}; $c->stash(articles => $articles); if ($c->stash('format') && $c->stash('format') eq 'rss') { $c->stash( last_modified => $last_modified, last_created => $last_created, template => 'index' ); } else { $c->stash(layout => 'wrapper'); } _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } => 'tag'; get '/tags' => sub { my $c = shift; my $tags = get_tags(); $c->stash(layout => 'wrapper', tags => $tags); _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } => 'tags'; get '/articles/:year/:month/:alias' => sub { my $c = shift; my $articleid = $c->stash('year') . '/' . $c->stash('month') . '/' . $c->stash('alias'); my ($article, $pager) = get_article($articleid); unless ($article) { $c->stash(rendered => 1); $c->app->static->serve_404($c); return 1; } return 1 unless _is_modified($c, $article->{modified}); $c->stash(article => $article, pager => $pager, layout => 'wrapper'); $c->res->headers->header( 'Last-Modified' => Mojo::Date->new($article->{modified})); _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } => 'article'; get '/pages/:pageid' => sub { my $c = shift; my $pageid = $c->stash('pageid'); my $page = get_page($pageid); unless ($page) { $c->stash(rendered => 1); $c->app->static->serve_404($c); return 1; } #return 1 unless _is_modified($c, $page->{modified}); $c->stash(layout => 'wrapper', page => $page); $c->res->headers->header( 'Last-Modified' => Mojo::Date->new($page->{modified})); _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } => 'page'; get '/drafts/:draftid' => sub { my $c = shift; my $draftid = $c->stash('draftid'); my $draft = get_draft($draftid); unless ($draft) { $c->stash(rendered => 1); $c->app->static->serve_404($c); return 1; } #return 1 unless _is_modified($c, $page->{modified}); $c->stash(layout => 'wrapper', draft => $draft); $c->res->headers->header( 'Last-Modified' => Mojo::Date->new($draft->{modified})); _call_hook($c, 'prepare'); $c->render; _call_hook($c, 'finalize'); } => 'draft'; sub theme { my $publicdir = app->home->rel_dir($config{publicdir}); # CSS, JS auto import foreach my $type (qw/css js/) { $config{$type} = [map { s/^$publicdir\///; $_ } glob("$publicdir/bootylicious/themes/$config{theme}/*.$type")]; } } sub _read_config_from_file { my ($conf_file) = @_; app->log->debug("Reading configuration from $conf_file."); if (-e $conf_file) { if (open FILE, "<", $conf_file) { my @lines = ; close FILE; my $line = ''; foreach my $l (@lines) { next if $l =~ m/^\s*#/; $line .= $l; } my $json = Mojo::JSON->new; my $json_config = $json->decode($line) || {}; die $json->error if !$json_config && $json->error; %config = (%config, %$json_config); unshift @INC, $_ for ( ref $config{perl5lib} eq 'ARRAY' ? @{$config{perl5lib}} : $config{perl5lib}); } } else { app->log->debug("Configuration is not available."); } $ENV{SCRIPT_NAME} = $config{base} if defined $config{base}; # set proper templates base dir, if defined app->renderer->root(app->home->rel_dir($config{templatesdir})) if defined $config{templatesdir}; # set proper public base dir, if defined app->static->root(app->home->rel_dir($config{publicdir})) if defined $config{publicdir}; } sub _load_plugins { my $plugins_arrayref = shift; my $lib_dir = app->home->rel_dir('lib'); push @INC, $lib_dir; my @plugins; my $prev; while (my $plugin = shift @$plugins_arrayref) { if (ref($plugin) eq 'HASH') { next unless $plugins[-1]; $plugins[-1]->{args} = $plugin; } else { push @plugins, {name => $plugin, args => {}}; } } foreach my $plugin (@plugins) { _load_plugin($plugin->{name} => $plugin->{args}); } } sub _load_plugin { my ($class, $args) = @_; my $loader = Mojo::Loader->new; $class = Mojo::ByteStream->new($class)->camelize; $class = "Bootylicious::Plugin::$class"; app->log->debug("Loading plugin '$class'"); if (my $e = $loader->load($class)) { if (ref $e) { app->log->error($e); } else { app->log->error("Plugin not found: $class"); } return; } unless ($class->can('new')) { app->log->error(qq|Can't locate object method "new" via plugin '$class'|); return; } $args ||= {}; my $instance = $class->new(%$args); foreach my $hook (keys %hooks) { next unless $class->can("hook_$hook"); app->log->debug("Registering hook '$class\::hook_$hook'"); push @{$hooks{$hook}}, $instance; } } sub _call_hook { my $c = shift; my $hook = shift; my $method = "hook_$hook"; ref $_ eq 'CODE' ? $_->($c) : $_->$method($c) foreach @{$hooks{$hook}}; } sub _is_modified { my $c = shift; my ($last_modified) = @_; my $date = $c->req->headers->header('If-Modified-Since'); return 1 unless $date; return 1 unless Mojo::Date->new($date)->epoch == $last_modified; $c->res->code(304); $c->stash(rendered => 1); return 0; } sub get_tags { my $tags = {}; my ($articles) = get_articles(limit => 0); foreach my $article (@$articles) { foreach my $tag (@{$article->{tags}}) { $tags->{$tag}->{count} ||= 0; $tags->{$tag}->{count}++; } } return $tags; } sub get_articles { my %params = @_; $params{limit} ||= 0; my $root = ($config{articlesdir} =~ m/^\//) ? $config{articlesdir} : app->home->rel_dir($config{articlesdir}); my $pager = {}; my @files = sort { $b cmp $a } glob($root . '/*.*'); if ($params{limit}) { my $min = 0; if ($params{timestamp}) { my $i = 0; foreach my $file (@files) { File::Basename::basename($file) =~ m/^([^\-]+)-/; if ($1 le $params{timestamp}) { $min = $i; last; } $i++; } } my $max = $min + $params{limit}; if ($min > $params{limit} - 1 && $files[$min - $params{limit}]) { $pager->{prev} = $1 if File::Basename::basename($files[$min - $params{limit}]) =~ m/^([^\-]+)-/; } if ($max < scalar(@files) && $files[$max]) { $pager->{next} = $1 if File::Basename::basename($files[$max]) =~ m/^([^\-]+)-/; } @files = splice(@files, $min, $params{limit}); } my @articles; foreach my $file (@files) { my $data = _parse_article($file); next unless $data && %$data; push @articles, $data; } return (\@articles, $pager); } sub get_article { my $articleid = shift; return unless $articleid; my ($year, $month, $alias) = split('/', $articleid); return unless $year && $month && $alias; my $root = ($config{articlesdir} =~ m/^\//) ? $config{articlesdir} : app->home->rel_dir($config{articlesdir}); my $timestamp_re = qr/^$year$month\d\dT.*?-$alias\./; my @files = sort { $b cmp $a } glob($root . '/*.*'); my $path; my ($prev, $next); for (my $i = 0; $i <= $#files; $i++) { $prev = $files[$i - 1] if $i > 0; $next = $files[$i + 1] if $i <= $#files; my $basename = File::Basename::basename($files[$i]); if ($basename =~ m/$timestamp_re/) { $path = $files[$i]; last; } } return unless $path && -r $path; my $pager = {}; if ($next && $next ne $path) { ($pager->{next}) = _parse_article($next); } if ($prev && $prev ne $path) { ($pager->{prev}) = _parse_article($prev); } return (_parse_article($path), $pager); } sub get_draft { my $alias = shift; return unless $alias; my $root = ($config{draftsdir} =~ m/^\//) ? $config{draftsdir} : app->home->rel_dir($config{draftsdir}); my @files = glob($root . '/' . '*' . $alias . ".*"); if (@files > 1) { app->log->warn('More then one draft is available ' . 'with the same alias'); } my $path = $files[0]; return unless $path && -r $path; return _parse_article($path); } sub get_page { my $pageid = shift; return unless $pageid; my $root = ($config{pagesdir} =~ m/^\//) ? $config{pagesdir} : app->home->rel_dir($config{pagesdir}); my @files = glob($root . '/' . $pageid . ".*"); if (@files > 1) { app->log->warn('More then one page is available ' . 'with the same extension'); } my $path = $files[0]; return unless $path && -r $path; return _parse_article($path); } sub url { my $c = shift; my $name = shift; my $value = shift; if (!defined $name || $name eq '') { return ''; } elsif ($name eq 'root') { return $c->url_for(index => (format => '', @_)); } elsif ($name eq 'index') { return $c->url_for(index => ($value, @_)); } elsif ($name eq 'article') { return $c->url_for( article => ( year => $value->{year}, month => $value->{month}, alias => $value->{name}, format => 'html' ) ); } elsif ($name eq 'tag') { return $c->url_for(tag => (tag => $value, format => 'html', @_)); } elsif ($name eq 'pager') { return $c->url_for('index', format => 'html') . "?timestamp=$value"; } } sub url_abs { url(@_)->to_abs } sub date { my $c = shift; my $epoch = shift; my $fmt = shift; $fmt ||= config('datefmt'); my $t = Time::Piece->gmtime($epoch); return b($t->strftime($fmt))->decode('utf-8'); } sub date_rss { my $c = shift; my $epoch = shift; return Mojo::Date->new($epoch)->to_string; } my %_articles; sub _parse_article { my $path = shift; return unless $path; my $modified = (stat($path))[9]; return $_articles{$path} if $_articles{$path} && $_articles{$path}->{modified} == $modified; my ($name, $ext) = ($path =~ m/\/([^\/]+)\.([^.]+)$/); my ($year, $month, $day, $hour, $minute, $second); if ($name =~ s/(\d\d\d\d)(\d\d)(\d\d)(?:T(\d\d):?(\d\d):?(\d\d))?-//) { ($year, $month, $day, $hour, $minute, $second) = ($1, $2, $3, ($4 || '00'), ($5 || '00'), ($6 || '00')); $second ||= 0; $minute ||= 0; $hour ||= 0; } else { ($second, $minute, $hour, $day, $month, $year) = gmtime($modified); $year += 1900; $month += 1; } my $timestamp = $year . sprintf('%02d', $month) . sprintf('%02d', $day) . 'T' . sprintf('%02d', $hour) . ':' . sprintf('%02d', $minute) . ':' . sprintf('%02d', $second); my $created = 0; eval { $created = Time::Local::timegm($second, $minute, $hour, $day, $month - 1, $year - 1900); }; if ($@ || $created < 0) { app->log->debug("Ignoring $path: wrong timestamp"); return; } unless (open FILE, "<:encoding(UTF-8)", $path) { app->log->error("Can't open file: $path: $!"); return; } my $string = join("", ); close FILE; my $parser = _get_parser($ext); return unless $parser; my $metadata = _parse_metadata(\$string); my $cuttag = $config{cuttag}; my ($head, $tail) = ($string, ''); my $preview_link = ''; if ($head =~ s{(.*?)\Q$cuttag\E(?: (.*?))?(?:\n|\r|\n\r)(.*)}{$1}s) { $tail = $3; $preview_link = $2 || $config{cuttext}; } my $data = $parser->($head, $tail); unless ($data) { app->log->debug("Ignoring $path: parser error"); return; } my $content = $data->{tail} ? $data->{head} . '' . $data->{tail} : $data->{head}; my $preview = $data->{tail} ? $data->{head} : ''; return $_articles{$path} = { path => $path, name => $name, created => $created, modified => $modified, timestamp => $timestamp, year => $year, month => $month, day => $day, hour => $hour, minute => $minute, second => $second, title => $metadata->{title} || $name, description => $metadata->{description} || '', link => $metadata->{link} || '', tags => $metadata->{tags} || [], preview => $preview, preview_link => $preview_link, content => $content }; } my %_parsers; sub _get_parser { my $ext = shift; my $parser = \&_parse_article_pod; if ($ext eq 'ep') { $parser = sub { my ($head_string, $tail_string) = @_; my $head = ''; my $tail = ''; my $mt = Mojo::Template->new; $head = $mt->render($head_string); if ($tail_string) { $tail = $mt->render($tail_string); } return { head => $head, tail => $tail }; } } elsif ($ext ne 'pod') { my $parser_class = 'Bootylicious::Parser::' . Mojo::ByteStream->new($ext)->camelize; if ($_parsers{$parser_class}) { $parser = $_parsers{$parser_class}; } else { eval "require $parser_class"; if ($@) { app->log->error($@); return; } #my $loader = Mojo::Loader->new; #if (my $e = $loader->load($parser_class)) { #if (ref $e) { #$c->app->log->error($e); #} #else { #$c->app->log->error("Unknown parser: $parser_class"); #} #return; #} $parser = $_parsers{$parser_class} = $parser_class->new->parser_cb; } } return $parser; } sub _parse_metadata { my $string = shift; $$string =~ s/^((.*?)(?:\n\n|\n\r\n\r|\r\r))//s; return {} unless $2; my $original = $1; my $data = $2; my $metadata = {}; while ($data =~ s/^(.*?):\s*(.*?)(?:\n|\n\r|\r|$)//s) { my $key = lc $1; my $value = $2; if ($key eq 'tags') { my $tmp = $value || ''; $value = []; @$value = map { s/^\s+//; s/\s+$//; $_ } split(/,/, $tmp); } $metadata->{$key} = $value; } unless (%$metadata) { $$string = $original . $$string; } return $metadata; } sub _parse_article_pod { my ($head_string, $tail_string) = @_; my $parser = Pod::Simple::HTML->new; $parser->force_title(''); $parser->html_header_before_title(''); $parser->html_header_after_title(''); $parser->html_footer(''); my $title = ''; my $head = ''; my $tail = ''; $parser->output_string(\$head); $head_string = "=pod\n\n$head_string"; eval { $parser->parse_string_document($head_string) }; return if $@; # Hacking $head =~ s{\n}{}g; $head =~ s{(.*?)}{$1}sg; $head =~ s{^\s*

NAME

\s*

(.*?)

}{}sg; $title = $1; if ($tail_string) { $tail_string = "=pod\n$tail_string"; my $parser = Pod::Simple::HTML->new; $parser->force_title(''); $parser->html_header_before_title(''); $parser->html_header_after_title(''); $parser->html_footer(''); $parser->output_string(\$tail); eval { $parser->parse_string_document($tail_string) }; return if $@; $tail =~ s{\n}{}g; $tail =~ s{(.*?)}{$1}sg; } my $link = ''; if ($head =~ s{^\s*

LINK

\s*

(.*?)

}{}sg) { $link = $1; } my $tags = []; if ($head =~ s{^\s*

TAGS

\s*

(.*?)

}{}sg) { my $list = $1; $list =~ s/(?:\r|\n)*//gs; @$tags = map { s/^\s+//; s/\s+$//; $_ } split(/,/, $list); } return { title => $title, link => $link, tags => $tags, head => $head, tail => $tail }; } _call_hook(app, 'init'); theme if $config{'theme'}; shagadelic(@ARGV ? @ARGV : $config{'server'}); __DATA__ @@ index.html.ep % stash description => config('descr'); % foreach my $article (@{$articles}) {

% if ($article->{link}) { » <%= $article->{title} %>   % } % else { <%= $article->{title} %> % }

<%= date $article->{created} %>
% foreach my $tag (@{$article->{tags}}) { <%= $tag %> % }
% if ($article->{preview}) { <%== $article->{preview} %> % } % else { <%== $article->{content} %> % }
% }
% if ($pager->{prev}) { ← Later % } % else { ← Later % } % if ($pager->{next}) { Earlier → % } % else { Earlier → % }
@@ archive.html.ep % stash title => strings('archive'), description => strings('archive-description'); % my $tmp; % my $new = 0;

<%= strings 'archive' %>


% foreach my $article (@$articles) { % if (!$tmp || $article->{year} ne $tmp->{year}) { <%== "" if $tmp %> <%= $article->{year} %>
@@ index.rss.ep <%= config 'title' %> <%= url_abs 'root' %> <%= config 'descr' %> <%= date_rss $last_created %> <%= date_rss $last_created %> Mojolicious::Lite % foreach my $article (@$articles) { % my $link = url_abs(article => $article); <%= $article->{title} %> <%= $link %> <%= $article->{preview} || $article->{content} %> % if ($article->{link}) { % my $permalink = qq||; <%= $permalink %> % } % foreach my $tag (@{$article->{tags}}) { <%= $tag %> % } <%= date_rss($article->{created}) %> <%= $link %> % } @@ tags.html.ep % stash title => strings('tags'), description => strings('tags-description');

<%= strings 'tags' %>


% foreach my $tag (keys %$tags) { <%= $tag %> (<%= $tags->{$tag}->{count} %>) % }
@@ tag.html.ep % stash title => $tag, description => strings('tag-description', $tag);

<%= strings 'tag' %> <%= $tag %> RSS


% foreach my $article (@$articles) { <%= $article->{title} %>
<%= date $article->{created} %>
% }
@@ article.html.ep % stash title => $article->{title}, description => $article->{description};

% if ($article->{link}) { » <%= $article->{title} %> % } else { <%= $article->{title} %> % }

<%= date $article->{created} %> % if ($article->{created} != $article->{modified}) { , modified <%= date $article->{modified} %> % }
% foreach my $tag (@{$article->{tags}}) { <%= $tag %> % }
<%== $article->{content} %>
% if ($pager->{prev}) { ← <%= $pager->{prev}->{title} %> | % } <%= strings('archive') %> % if ($pager->{next}) { | <%= $pager->{next}->{title}%> → % }
@@ page.html.ep % stash title => $page->{title}, description => $page->{description};

<%= $page->{title} %>

<%== $page->{content} %>
@@ draft.html.ep % stash title => $draft->{title}, description => strings('draft');

<%= $draft->{title} %>

<%== $draft->{content} %>
@@ layouts/wrapper.html.ep %# $c->res->headers->content_type('text/html; charset=utf-8'); <%= $title ? "$title / " : '' %><%= config 'title' %> % if ($description) { % } % foreach my $meta (@{config('meta')}) { {$key}\" " %> % } /> % } % foreach my $file (@{config('css')}) { % } % if (!@{config('css')}) { % }
<%= content %>
% foreach my $file (@{config('js')}) {