#!/usr/bin/env perl BEGIN { use FindBin; unshift @INC, "$FindBin::Bin/mojo/lib" } use Mojolicious::Lite; use Mojo::ByteStream 'b'; use Mojo::Command; use Mojo::Date; use Mojo::JSON; use Mojo::Loader; use Mojo::Template; require File::Basename; # Don't use set locale unless it is explicitly specified via a config file $ENV{LC_ALL} = 'C'; require Time::Piece; require Time::Local; my $ALIAS_RE = qr/[a-zA-Z0-9-_]+/; my $TAG_RE = qr/[a-zA-Z0-9]+/; use constant MARKDOWN => eval { require Text::Markdown; 1 }; our $VERSION = '0.910102'; my $config = { perl5lib => '', loglevel => 'error', author => 'whoami', email => '', title => 'Just another blog', about => 'Perl hacker', descr => 'I do not know if I need this', articlesdir => 'articles', pagesdir => 'pages', draftsdir => 'drafts', publicdir => 'public', templatesdir => 'templates', 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', 'later' => 'Later', 'earlier' => 'Earlier', 'not-found' => 'The page you are looking for was not found', 'error' => 'Internal error occuried :(' }, template_handler => 'ep', }; app->home->parse($ENV{BOOTYLICIOUS_HOME}) if $ENV{BOOTYLICIOUS_HOME}; _read_config_from_file(); plugin 'charset' => {charset => 'utf-8'}; plugin 'pod_renderer'; plugin 'tag_helpers'; app->log->level($config->{loglevel}); app->renderer->default_handler(config('template_handler')); 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( href_to_article => sub { my $self = shift; my $article = shift; return $self->url_for( article => ( year => $article->{year}, month => $article->{month}, alias => $article->{name}, format => 'html' ) ); } ); app->renderer->add_helper( link_to_article => sub { my $self = shift; my $article = shift; my $href = $self->helper('href_to_article' => $article); if ($article->{link}) { my $string = ''; $string .= $self->helper(link_to => $href => sub { $article->{title} }); $string .= ' '; $string .= $self->helper(link_to => $article->{link} => sub { "»" }); return Mojo::ByteStream->new($string); } return $self->helper(link_to => $href => sub { $article->{title} }); } ); app->renderer->add_helper( link_to_tag => sub { my $self = shift; my $tag = shift; my $cb = ref $_[-1] eq 'CODE' ? $_[-1] : sub {$tag}; my $args = ref $_[0] eq 'HASH' ? $_[0] : {}; return $self->helper( link_to => tag => {tag => $tag, format => 'html', %$args} => $cb); } ); app->renderer->add_helper( link_to_page => sub { my $self = shift; my $timestamp = shift; return $self->helper(link_to => 'index' => {timestamp => $timestamp, format => 'html'} => @_); } ); # Helpers for plugins #app->renderer->add_helper(get_articles => sub { get_articles(@_) }); app->renderer->add_helper( strings => sub { my $self = shift; my $string = config('strings')->{$_[0]}; for (my $i = 0; $i < @_; $i++) { $string =~ s/\[_$i\]/$_[$i]/; } return $string; } ); _load_plugins(); sub config { if (@_) { return $config->{$_[0]} if @_ == 1; $config = {%$config, @_}; } return $config; } under sub { my $self = shift; my $path = $self->req->url->path->to_string; return 1 unless $path; return 1 if $path =~ m{/$}; return 1 if $path =~ m{\.(?:[a-z]+)$}; my $canonical_location = $self->req->url->clone->path($path . '.html')->to_abs; $self->app->log->debug("Path is not canonical: " . $self->req->url); $self->app->log->debug("Redirecting to: " . $canonical_location); $self->redirect_to($canonical_location); # Stop return 0; }; sub index { my $self = shift; my $timestamp = $self->stash('timestamp') || 0; my $article = {}; my ($articles, $pager) = get_articles($self, 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($self, $last_modified); } $self->stash( article => $article, articles => $articles, pager => $pager ); $self->res->headers->header('Last-Modified' => Mojo::Date->new($last_modified)); $self->stash(template => 'index'); if ($self->stash('format') && $self->stash('format') eq 'rss') { $self->stash( last_created => $last_created, last_modified => $last_modified, ); } else { $self->stash(layout => 'wrapper', title => ''); } } get '/' => \&index => 'root'; get '/index/:timestamp' => {timestamp => ''} => \&index => 'index'; get '/archive' => sub { my $self = shift; my $root = $self->app->home; my $last_modified = Mojo::Date->new; my ($articles) = get_articles($self, limit => 0); if (@$articles) { $last_modified = $articles->[0]->{modified}; return 1 unless _is_modified($self, $last_modified); } $self->res->headers->header('Last-Modified' => $last_modified); $self->stash( layout => 'wrapper', articles => $articles, last_modified => $last_modified, ); } => 'archive'; get '/tags/:tag' => [tag => $TAG_RE] => sub { my $self = shift; my $tag = $self->stash('tag'); my ($articles) = get_articles($self, limit => 0); $articles = [ grep { grep {m/^\Q$tag\E$/} @{$_->{tags}} } @$articles ]; return $self->render_not_found unless @$articles; my $last_modified = $articles->[0]->{modified}; return 1 unless _is_modified($self, $last_modified); $self->res->headers->header('Last-Modified' => Mojo::Date->new($last_modified)); my $last_created = $articles->[0]->{created}; $self->stash(articles => $articles); if ($self->stash('format') && $self->stash('format') eq 'rss') { $self->stash( last_modified => $last_modified, last_created => $last_created, template => 'index' ); } else { $self->stash(layout => 'wrapper'); } } => 'tag'; get '/tags' => sub { my $self = shift; my $tags = get_tags($self); $self->stash(layout => 'wrapper', tags => $tags); } => 'tags'; get '/articles/:year/:month/:alias' => [year => qr/\d+/, month => qr/\d+/, alias => $ALIAS_RE] => sub { my $self = shift; my $articleid = $self->stash('year') . '/' . $self->stash('month') . '/' . $self->stash('alias'); my ($article, $pager) = get_article($self, $articleid); return $self->render_not_found unless $article; return 1 unless _is_modified($self, $article->{modified}); $self->stash(article => $article, pager => $pager, layout => 'wrapper'); $self->res->headers->header( 'Last-Modified' => Mojo::Date->new($article->{modified})); } => 'article'; get '/pages/:pageid' => [pageid => $ALIAS_RE] => sub { my $self = shift; my $pageid = $self->stash('pageid'); my $page = get_page($self, $pageid); return $self->render_not_found unless $page; return 1 unless _is_modified($self, $page->{modified}); $self->stash(layout => 'wrapper', page => $page); $self->res->headers->header( 'Last-Modified' => Mojo::Date->new($page->{modified})); } => 'page'; get '/drafts/:draftid' => [draftid => $ALIAS_RE]=> sub { my $self = shift; my $draftid = $self->stash('draftid'); my $draft = get_draft($self, $draftid); return $self->render_not_found unless $draft; return 1 unless _is_modified($self, $draft->{modified}); $self->stash(layout => 'wrapper', draft => $draft); $self->res->headers->header( 'Last-Modified' => Mojo::Date->new($draft->{modified})); } => '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 { $config = plugin json_config => {default => $config}; # Additional Perl modules if ($config->{perl5lib}) { push @INC, $_ for ( ref $config->{perl5lib} eq 'ARRAY' ? @{$config->{perl5lib}} : $config->{perl5lib}); } $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 @{$config->{plugins}}) { if (ref($plugin) eq 'HASH') { next unless $plugins[-1]; $plugins[-1]->{args} = $plugin; } else { push @plugins, {name => $plugin, args => {}}; } } push @{app->plugins->namespaces}, $_ for @{$config->{plugins_namespaces}}; push @{app->plugins->namespaces}, 'Bootylicious::Plugin'; foreach my $plugin (@plugins) { plugin($plugin->{name} => $plugin->{args}); } } sub _is_modified { my $self = shift; my ($last_modified) = @_; my $date = $self->req->headers->header('If-Modified-Since'); return 1 unless $date; return 1 unless Mojo::Date->new($date)->epoch >= $last_modified; $self->render_text('', status => 304); return 0; } sub get_tags { my $self = shift; my $tags = {}; my ($articles) = get_articles($self, limit => 0); foreach my $article (@$articles) { foreach my $tag (@{$article->{tags}}) { $tags->{$tag}->{count} ||= 0; $tags->{$tag}->{count}++; } } return $tags; } sub get_articles { my $self = shift; 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($self, $file); next unless $data && %$data; push @articles, $data; } return (\@articles, $pager); } sub get_article { my $self = shift; 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}); $month = sprintf("%02d", $month); my $timestamp_re = qr/^$year$month\d\d(T.*?)?-\Q$alias\E\./; my @files = sort { $b cmp $a } glob($root . '/*.*'); my $path; my ($prev, $next); for (my $i = 0; $i <= $#files; $i++) { utf8::decode($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($self, $next); } if ($prev && $prev ne $path) { ($pager->{prev}) = _parse_article($self, $prev); } return (_parse_article($self, $path), $pager); } sub get_draft { my $self = shift; 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($self, $path); } sub get_page { my $self = shift; 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($self, $path); } sub date { my $self = 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 $self = shift; my $epoch = shift; return Mojo::Date->new($epoch)->to_string; } my %_articles; sub _parse_article { my $self = shift; my $path = shift; return unless $path; my $modified = (stat($path))[9]; # Cache 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 = sprintf('%d%02d%02dT%02d%02d%02d', $year, $month, $day, $hour, $minute, $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; } my $string = _slurp_file($path); return unless defined $string; 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}; } $head = $parser->($self, $head); app->log->debug('Error while parsing ' . $metadata->{title}), return unless defined $head; $tail = $parser->($self, $tail) if $tail; my ($preview, $content); if ($tail) { $content = $head . '' . $tail; $preview = $head; } else { $content = $head; $preview = ''; } return $_articles{$path} = { path => $path, name => $name, created => $created, modified => $modified, timestamp => $timestamp, year => $year, month => $month, day => $day, title => $metadata->{title} || $name, description => $metadata->{description} || '', link => $metadata->{link} || '', tags => $metadata->{tags} || [], preview => $preview, preview_link => $preview_link, content => $content, raw_content => $string, }; } sub _slurp_file { my $file = shift; open my $fh, '<:encoding(UTF-8)', $file or return; return do { local $/; <$fh> }; } my %_parsers; sub _get_parser { my $ext = shift; return \&_parse_pod if $ext eq 'pod'; return \&_parse_md if $ext eq 'md' && MARKDOWN; return; } 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_pod { $_[0]->helper(pod_to_html => $_[1]) } sub _parse_md { Text::Markdown->new->markdown($_[1]) } theme if $config->{'theme'}; app->start; 1; __DATA__ @@ index.html.ep % stash description => config('descr'); % if (!@$articles) {
Nothing here yet :(
% } %= include 'index-item', article => $_ for @$articles; %= include 'index-pager', pager => $pager; @@ index-item.html.ep

<%= link_to_article $article %>

<%= date $article->{created} %>
% foreach my $tag (@{$article->{tags}}) { <%= link_to_tag $tag %> % }
% if ($article->{preview}) { <%== $article->{preview} %>
<%= $article->{preview_link} %>
% } % else { <%== $article->{content} %> % }
@@ index-pager.html.ep
% if ($pager->{prev}) { ← <%= link_to_page $pager->{prev} => {%><%= strings 'later' %><%}%> % } % else { ← <%= strings 'later' %> % } % if ($pager->{next}) { <%= link_to_page $pager->{next} => {%><%= strings 'earlier' %><%}%> → % } % else { <%= strings '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_for('root')->to_abs %> <%= config 'descr' %> <%= date_rss $last_created %> <%= date_rss $last_created %> Mojolicious::Lite % foreach my $article (@$articles) { % my $link = href_to_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) { <%= link_to_tag $tag %> (<%= $tags->{$tag}->{count} %>) % }
@@ tag.html.ep % stash title => $tag, description => strings('tag-description', $tag);

<%= strings 'tag' %> <%= $tag %> <%= link_to_tag $tag => { format => 'rss'} => {%>RSS<%}%>


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

<%= link_to_article $article %>

<%= date $article->{created} %> % if ($article->{created} != $article->{modified}) { , modified <%= date $article->{modified} %> % }
% foreach my $tag (@{$article->{tags}}) { <%= link_to_tag $tag %> % }
<%== $article->{content} %> %= include 'article-pager', pager => $pager;
@@ article-pager.html.ep
% if ($pager->{prev}) { ← <%= link_to_article $pager->{prev} %>  | % } <%= strings('archive') %> % if ($pager->{next}) { | <%= link_to_article $pager->{next} %> → % }
@@ 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} %>
@@ not_found.html.ep % stash title => 'Not found', description => 'Not found', layout => 'wrapper';

404


<%= strings 'not-found' %>
@@ exception.html.ep % stash title => 'Not found', description => 'Not found', layout => 'wrapper';

500


<%= strings 'error' %>
@@ layouts/wrapper.html.ep <%= $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')}) {