Factor out readability check in default entries().
[matthijs/upstream/blosxom.git] / blosxom.cgi
1 #!/usr/bin/perl
2
3 # Blosxom
4 # Author: Rael Dornfest (2002-2003), The Blosxom Development Team (2005-2009)
5 # Version: 2.1.2 ($Id: blosxom.cgi,v 1.98 2009/07/19 17:18:37 xtaran Exp $)
6 # Home/Docs/Licensing: http://blosxom.sourceforge.net/
7 # Development/Downloads: http://sourceforge.net/projects/blosxom
8
9 package blosxom;
10
11 =head1 NAME
12
13 blosxom - A lightweight yet feature-packed weblog
14
15 =head1 SYNOPSIS
16
17 B<blosxom> is a simple web log (blog) CGI script written in perl.
18
19 =head1 DESCRIPTION
20
21 B<Blosxom> (pronounced "I<blossom>") is a lightweight yet feature-packed
22 weblog application designed from the ground up with simplicity,
23 usability, and interoperability in mind.
24
25 Fundamental is its reliance upon the file system, folders and files
26 as its content database. Blosxom's weblog entries are plain text
27 files like any other. Write from the comfort of your favorite text
28 editor and hit the Save button. Create, edit, rename, and delete entries
29 on the command-line, via FTP, WebDAV, or anything else you
30 might use to manipulate your files. There's no import or export; entries
31 are nothing more complex than title on the first line, body being
32 everything thereafter.
33
34 Despite its tiny footprint, Blosxom doesn't skimp on features, sporting
35 the majority of features one would find in any other Weblog application.
36
37 Blosxom is simple, straightforward, minimalist Perl affording even the
38 dabbler an opportunity for experimentation and customization. And
39 last, but not least, Blosxom is open source and free for the taking and
40 altering.
41
42 =head1 USAGE
43
44 Write a weblog entry, and place it into the main data directory. Place
45 the the title is on the first line; the body is everything afterwards.
46 For example, create a file named I<first.txt> and put in it something
47 like this:
48
49   First Blosxom Post!
50
51   I have successfully installed blosxom on this system.  For more
52   information on blosxom, see the author's <a
53   href="http://blosxom.sourceforge.net/">blosxom site</a>.
54
55 Place the file in the directory under the I<$datadir> points to. Be
56 sure to change the default location to be somewhere accessable by the
57 web server that runs blosxom as a CGI program.
58
59 =cut
60
61 # --- Configurable variables -----
62
63 # What's this blog's title?
64 $blog_title = "My Weblog";
65
66 # What's this blog's description (for outgoing RSS feed)?
67 $blog_description = "Yet another Blosxom weblog.";
68
69 # What's this blog's primary language (for outgoing RSS feed)?
70 $blog_language = "en";
71
72 # What's this blog's text encoding ?
73 $blog_encoding = "UTF-8";
74
75 # Where are this blog's entries kept?
76 $datadir = "/Library/WebServer/Documents/blosxom";
77
78 # What's my preferred base URL for this blog (leave blank for
79 # automatic)?
80 $url = "";
81
82 # Should I stick only to the datadir for items or travel down the
83 # directory hierarchy looking for items?  If so, to what depth?
84 #
85 # 0 = infinite depth (aka grab everything), 1 = datadir only,
86 # n = n levels down
87
88 $depth = 0;
89
90 # How many entries should I show on the home page?
91 $num_entries = 40;
92
93 # What file extension signifies a blosxom entry?
94 $file_extension = "txt";
95
96 # What is the default flavour?
97 $default_flavour = "html";
98
99 # Should I show entries from the future (i.e. dated after now)?
100 $show_future_entries = 0;
101
102 # --- Plugins (Optional) -----
103
104 # File listing plugins blosxom should load (if empty blosxom will load
105 # all plugins in $plugin_dir and $plugin_path directories)
106 $plugin_list = "";
107
108 # Where are my plugins kept?
109 $plugin_dir = "";
110
111 # Where should my plugins keep their state information?
112 $plugin_state_dir = "$plugin_dir/state";
113
114 # Additional plugins location. A list of directories, separated by ';'
115 # on windows, ':' everywhere else.
116 $plugin_path = "";
117
118 # --- Static Rendering -----
119
120 # Where are this blog's static files to be created?
121 $static_dir = "/Library/WebServer/Documents/blog";
122
123 # What's my administrative password (you must set this for static
124 # rendering)?
125 $static_password = "";
126
127 # What flavours should I generate statically?
128 @static_flavours = qw/html rss/;
129
130 # Should I statically generate individual entries?
131 # 0 = no, 1 = yes
132 $static_entries = 0;
133
134 # --- Advanced Encoding Options -----
135
136 # Should I encode entities for xml content-types? (plugins can turn
137 # this off if they do it themselves)
138 $encode_xml_entities = 1;
139
140 # Should I encode 8 bit special characters, e.g. umlauts in URLs, e.g.
141 # convert an ISO-Latin-1 \"o to %F6? (off by default for now; plugins
142 # can change this, too)
143 $encode_8bit_chars = 0;
144
145 # RegExp matching all characters which should be URL encoded in links.
146 # Defaults to anything but numbers, letters, slash, colon, dash,
147 # underscore and dot.
148 $url_escape_re = qr([^-/a-zA-Z0-9:._]);
149
150 # --------------------------------
151
152 =head1 ENVIRONMENT
153
154 =over
155
156 =item B<BLOSXOM_CONFIG_FILE>
157
158 Points to the location of the configuration file. This will be
159 considered as first option, if it's set.
160
161
162 =item B<BLOSXOM_CONFIG_DIR>
163
164 The here named directory will be tried unless the above mentioned
165 environment variable is set and tested for a contained blosxom.conf
166 file.
167
168
169 =back
170
171
172 =head1 FILES
173
174 =over
175
176 =item B</usr/lib/cgi-bin/blosxom>
177
178 The CGI script itself. Please note that the location might depend on
179 your installation.
180
181 =item B</etc/blosxom/blosxom.conf>
182
183 The default configuration file location. This is rather taken as last
184 ressort if no other configuration location is set through environment
185 variables.
186
187 =back
188
189
190 =head1 AUTHOR
191
192 Rael Dornfest <rael@oreilly.com> was the original author of blosxom. The
193 development was picked up by a team of dedicated users of blosxom since
194 2005. See <I<http://blosxom.sourceforge.net/>> for more information.
195
196 =cut
197
198 use vars qw!
199     $version
200     $blog_title
201     $blog_description
202     $blog_language
203     $blog_encoding
204     $datadir
205     $url
206     %template
207     $template
208     $depth
209     $num_entries
210     $file_extension
211     $default_flavour
212     $static_or_dynamic
213     $config_dir
214     $plugin_list
215     $plugin_path
216     $plugin_dir
217     $plugin_state_dir
218     @plugins
219     %plugins
220     $static_dir
221     $static_password
222     @static_flavours
223     $static_entries
224     $path_info_full
225     $path_info
226     $path_info_yr
227     $path_info_mo
228     $path_info_da
229     $path_info_mo_num
230     $flavour
231     %month2num
232     @num2month
233     $interpolate
234     $entries
235     $output
236     $header
237     $show_future_entries
238     %files
239     %indexes
240     %others
241     $encode_xml_entities
242     $encode_8bit_chars
243     $url_escape_re
244     $content_type
245     !;
246
247 use strict;
248 use FileHandle;
249 use File::Find;
250 use File::stat;
251 use Time::Local;
252 use CGI qw/:standard :netscape/;
253
254 $version = "2.1.2+dev";
255
256 # Load configuration from $ENV{BLOSXOM_CONFIG_DIR}/blosxom.conf, if it exists
257 my $blosxom_config;
258 if ( $ENV{BLOSXOM_CONFIG_FILE} && -r $ENV{BLOSXOM_CONFIG_FILE} ) {
259     $blosxom_config = $ENV{BLOSXOM_CONFIG_FILE};
260     ( $config_dir = $blosxom_config ) =~ s! / [^/]* $ !!x;
261 }
262 else {
263     for my $blosxom_config_dir ( $ENV{BLOSXOM_CONFIG_DIR}, '/etc/blosxom',
264         '/etc' )
265     {
266         if ( -r "$blosxom_config_dir/blosxom.conf" ) {
267             $config_dir     = $blosxom_config_dir;
268             $blosxom_config = "$blosxom_config_dir/blosxom.conf";
269             last;
270         }
271     }
272 }
273
274 # Load $blosxom_config
275 if ($blosxom_config) {
276     if ( -r $blosxom_config ) {
277         eval { require $blosxom_config }
278             or warn "Error reading blosxom config file '$blosxom_config'"
279             . ( $@ ? ": $@" : '' );
280     }
281     else {
282         warn "Cannot find or read blosxom config file '$blosxom_config'";
283     }
284 }
285
286 my $fh = new FileHandle;
287
288 %month2num = (
289     nil => '00',
290     Jan => '01',
291     Feb => '02',
292     Mar => '03',
293     Apr => '04',
294     May => '05',
295     Jun => '06',
296     Jul => '07',
297     Aug => '08',
298     Sep => '09',
299     Oct => '10',
300     Nov => '11',
301     Dec => '12'
302 );
303 @num2month = sort { $month2num{$a} <=> $month2num{$b} } keys %month2num;
304
305 # Use the stated preferred URL or figure it out automatically. Set
306 # $url manually in the config section above if CGI.pm doesn't guess
307 # the base URL correctly, e.g. when called from a Server Side Includes
308 # document or so.
309 unless ($url) {
310     $url = url();
311
312     # Unescape %XX hex codes (from URI::Escape::uri_unescape)
313     $url =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
314
315     # Support being called from inside a SSI document
316     $url =~ s/^included:/http:/ if $ENV{SERVER_PROTOCOL} eq 'INCLUDED';
317
318     # Remove PATH_INFO if it is set but not removed by CGI.pm. This
319     # seems to happen when used with Apache's Alias directive or if
320     # called from inside a Server Side Include document. If that
321     # doesn't help either, set $url manually in the configuration.
322     $url =~ s/\Q$ENV{PATH_INFO}\E$// if defined $ENV{PATH_INFO};
323
324     # NOTE:
325     #
326     # There is one case where this code does more than necessary, too:
327     # If the URL requested is e.g. http://example.org/blog/blog and
328     # the base URL is correctly determined as http://example.org/blog
329     # by CGI.pm, then this code will incorrectly normalize the base
330     # URL down to http://example.org, because the same string as
331     # PATH_INFO is part of the base URL, too. But this is such a
332     # seldom case and can be fixed by setting $url in the config file,
333     # too.
334 }
335
336 # The only modification done to a manually set base URL is to strip
337 # a trailing slash if present.
338
339 $url =~ s!/$!!;
340
341 # Drop ending any / from dir settings
342 $datadir    =~ s!/$!!;
343 $plugin_dir =~ s!/$!!;
344 $static_dir =~ s!/$!!;
345
346 # Fix depth to take into account datadir's path
347 $depth += ( $datadir =~ tr[/][] ) - 1 if $depth;
348
349 if (    !$ENV{GATEWAY_INTERFACE}
350     and param('-password')
351     and $static_password
352     and param('-password') eq $static_password )
353 {
354     $static_or_dynamic = 'static';
355 }
356 else {
357     $static_or_dynamic = 'dynamic';
358     param( -name => '-quiet', -value => 1 );
359 }
360
361 # Path Info Magic
362 # Take a gander at HTTP's PATH_INFO for optional blog name, archive yr/mo/day
363 my @path_info = split m{/}, path_info() || param('path');
364 $path_info_full = join '/', @path_info;    # Equivalent to $ENV{PATH_INFO}
365 shift @path_info;
366
367 # Flavour specified by ?flav={flav} or index.{flav}
368 $flavour = '';
369 if ( !( $flavour = param('flav') ) ) {
370     if ( $path_info[$#path_info] =~ /(.+)\.(.+)$/ ) {
371         $flavour = $2;
372         pop @path_info if $1 eq 'index';
373     }
374 }
375 $flavour ||= $default_flavour;
376
377 # Fix XSS in flavour name (CVE-2008-2236)
378 $flavour = blosxom_html_escape($flavour);
379
380 sub blosxom_html_escape {
381     my $string = shift;
382     my %escape = (
383         '<' => '&lt;',
384         '>' => '&gt;',
385         '&' => '&amp;',
386         '"' => '&quot;',
387         "'" => '&apos;'
388     );
389     my $escape_re = join '|' => keys %escape;
390     $string =~ s/($escape_re)/$escape{$1}/g;
391     $string;
392 }
393
394 # Global variable to be used in head/foot.{flavour} templates
395 $path_info = '';
396
397 # Add all @path_info elements to $path_info till we come to one that could be a year
398 while ( $path_info[0] && $path_info[0] !~ /^(19|20)\d{2}$/ ) {
399     $path_info .= '/' . shift @path_info;
400 }
401
402 # Pull date elements out of path
403 if ( $path_info[0] && $path_info[0] =~ /^(19|20)\d{2}$/ ) {
404     $path_info_yr = shift @path_info;
405     if ($path_info[0]
406         && ( $path_info[0] =~ /^(0\d|1[012])$/
407             || exists $month2num{ ucfirst lc $path_info_mo } )
408         )
409     {
410         $path_info_mo = shift @path_info;
411
412         # Map path_info_mo to numeric $path_info_mo_num
413         $path_info_mo_num
414             = $path_info_mo =~ /^\d{2}$/
415             ? $path_info_mo
416             : $month2num{ ucfirst lc $path_info_mo };
417         if ( $path_info[0] && $path_info[0] =~ /^[0123]\d$/ ) {
418             $path_info_da = shift @path_info;
419         }
420     }
421 }
422
423 # Add remaining path elements to $path_info
424 $path_info .= '/' . join( '/', @path_info );
425
426 # Strip spurious slashes
427 $path_info =~ s!(^/*)|(/*$)!!g;
428
429 # Define standard template subroutine, plugin-overridable at Plugins: Template
430 $template = sub {
431     my ( $path, $chunk, $flavour ) = @_;
432
433     do {
434         return join '', <$fh>
435             if $fh->open("< $datadir/$path/$chunk.$flavour");
436     } while ( $path =~ s/(\/*[^\/]*)$// and $1 );
437
438     # Check for definedness, since flavour can be the empty string
439     if ( defined $template{$flavour}{$chunk} ) {
440         return $template{$flavour}{$chunk};
441     }
442     elsif ( defined $template{error}{$chunk} ) {
443         return $template{error}{$chunk};
444     }
445     else {
446         return '';
447     }
448 };
449
450 # Bring in the templates
451 %template = ();
452 while (<DATA>) {
453     last if /^(__END__)$/;
454     my ( $ct, $comp, $txt ) = /^(\S+)\s(\S+)(?:\s(.*))?$/ or next;
455     $txt =~ s/\\n/\n/mg;
456     $template{$ct}{$comp} .= $txt . "\n";
457 }
458
459 # Plugins: Start
460 my $path_sep = $^O eq 'MSWin32' ? ';' : ':';
461 my @plugin_dirs = split /$path_sep/, $plugin_path;
462 unshift @plugin_dirs, $plugin_dir;
463 my @plugin_list = ();
464 my %plugin_hash = ();
465
466 # If $plugin_list is set, read plugins to use from that file
467 if ($plugin_list) {
468     if ( -r $plugin_list and $fh->open("< $plugin_list") ) {
469         @plugin_list = map { chomp $_; $_ } grep { /\S/ && !/^#/ } <$fh>;
470         $fh->close;
471     }
472     else {
473         warn "unable to read or open plugin_list '$plugin_list': $!";
474         $plugin_list = '';
475     }
476 }
477
478 # Otherwise walk @plugin_dirs to get list of plugins to use
479 if ( !@plugin_list && @plugin_dirs ) {
480     for my $plugin_dir (@plugin_dirs) {
481         next unless -d $plugin_dir;
482         if ( opendir PLUGINS, $plugin_dir ) {
483             for my $plugin (
484                 grep { /^[\w:]+$/ && !/~$/ && -f "$plugin_dir/$_" }
485                 readdir(PLUGINS) )
486             {
487
488                 # Ignore duplicates
489                 next if $plugin_hash{$plugin};
490
491                 # Add to @plugin_list and %plugin_hash
492                 $plugin_hash{$plugin} = "$plugin_dir/$plugin";
493                 push @plugin_list, $plugin;
494             }
495             closedir PLUGINS;
496         }
497     }
498     @plugin_list = sort @plugin_list;
499 }
500
501 # Load all plugins in @plugin_list
502 unshift @INC, @plugin_dirs;
503 foreach my $plugin (@plugin_list) {
504     my ( $plugin_name, $off ) = $plugin =~ /^\d*([\w:]+?)(_?)$/;
505     my $plugin_file = $plugin_list ? $plugin_name : $plugin;
506     my $on_off = $off eq '_' ? -1 : 1;
507
508     # Allow perl module plugins
509     # The -z test is a hack to allow a zero-length placeholder file in a
510     #   $plugin_path directory to indicate an @INC module should be loaded
511     if ( $plugin =~ m/::/ && ( $plugin_list || -z $plugin_hash{$plugin} ) ) {
512
513      # For Blosxom::Plugin::Foo style plugins, we need to use a string require
514         eval "require $plugin_file";
515     }
516     else
517     { # we try first to load from $plugin_dir before attempting from $plugin_path
518         eval        { require "$plugin_dir/$plugin_file" }
519             or eval { require $plugin_file };
520     }
521
522     if ($@) {
523         warn "error finding or loading blosxom plugin '$plugin_name': $@";
524         next;
525     }
526     if ( $plugin_name->start() and ( $plugins{$plugin_name} = $on_off ) ) {
527         push @plugins, $plugin_name;
528     }
529
530 }
531 shift @INC foreach @plugin_dirs;
532
533 # Plugins: Template
534 # Allow for the first encountered plugin::template subroutine to override the
535 # default built-in template subroutine
536 foreach my $plugin (@plugins) {
537     if ( $plugins{$plugin} > 0 and $plugin->can('template') ) {
538         if ( my $tmp = $plugin->template() ) {
539             $template = $tmp;
540             last;
541         }
542     }
543 }
544
545 # Provide backward compatibility for Blosxom < 2.0rc1 plug-ins
546 sub load_template {
547     return &$template(@_);
548 }
549
550 # Define default entries subroutine
551 $entries = sub {
552     my ( %files, %indexes, %others );
553     my $param_all = param('-all');
554     find(
555         sub {
556             my $curr_depth = $File::Find::dir =~ tr[/][];
557             return if $depth and $curr_depth > $depth;
558             return if !-r $File::Find::name;
559
560             if (
561
562                 # a match
563                 $File::Find::name
564                 =~ m!^$datadir/(?:(.*)/)?(.+)\.$file_extension$!
565
566                 # not an index, .file, and is readable
567                 and $2 ne 'index' and $2 !~ /^\./
568                 )
569             {
570
571                 # read modification time
572                 my $mtime = stat($File::Find::name)->mtime or return;
573
574                 # to show or not to show future entries
575                 return unless ( $show_future_entries or $mtime < time );
576
577                 # add the file and its associated mtime to the list of files
578                 $files{$File::Find::name} = $mtime;
579
580                 # static rendering bits
581                 my $static_file
582                     = "$static_dir/$1/index." . $static_flavours[0];
583                 if (   $param_all
584                     or !-f $static_file
585                     or stat($static_file)->mtime < $mtime )
586                 {
587                     $indexes{$1} = 1;
588                     my $d = join( '/', ( nice_date($mtime) )[ 5, 2, 3 ] );
589                     $indexes{$d} = $d;
590                     $indexes{ ( $1 ? "$1/" : '' ) . "$2.$file_extension" } = 1
591                         if $static_entries;
592                 }
593             }
594
595             # not an entries match
596             elsif ( !-d $File::Find::name ) {
597                 $others{$File::Find::name} = stat($File::Find::name)->mtime;
598             }
599         },
600         $datadir
601     );
602
603     return ( \%files, \%indexes, \%others );
604 };
605
606 # Plugins: Entries
607 # Allow for the first encountered plugin::entries subroutine to override the
608 # default built-in entries subroutine
609 foreach my $plugin (@plugins) {
610     if ( $plugins{$plugin} > 0 and $plugin->can('entries') ) {
611         if ( my $tmp = $plugin->entries() ) {
612             $entries = $tmp;
613             last;
614         }
615     }
616 }
617
618 my ( $files, $indexes, $others ) = &$entries();
619 %indexes = %$indexes;
620
621 # Static
622 if ( $static_or_dynamic eq 'static' ) {
623
624     param('-quiet') or print "Blosxom is generating static index pages...\n";
625
626     # Home Page and Directory Indexes
627     my %done;
628     foreach my $path ( sort keys %indexes ) {
629         my $p = '';
630         foreach ( ( '', split /\//, $path ) ) {
631             $p .= "/$_";
632             $p =~ s!^/!!;
633             next if $done{$p}++;
634             mkdir "$static_dir/$p", 0755
635                 unless ( -d "$static_dir/$p" or $p =~ /\.$file_extension$/ );
636             foreach $flavour (@static_flavours) {
637                 $content_type
638                     = ( &$template( $p, 'content_type', $flavour ) );
639                 $content_type =~ s!\n.*!!s;
640                 my $fn = $p =~ m!^(.+)\.$file_extension$! ? $1 : "$p/index";
641                 param('-quiet') or print "$fn.$flavour\n";
642                 my $fh_w = new FileHandle "> $static_dir/$fn.$flavour"
643                     or die "Couldn't open $static_dir/$p for writing: $!";
644                 $output = '';
645                 if ( $indexes{$path} == 1 ) {
646
647                     # category
648                     $path_info = $p;
649
650                     # individual story
651                     $path_info =~ s!\.$file_extension$!\.$flavour!;
652                     print $fh_w &generate( 'static', $path_info, '', $flavour,
653                         $content_type );
654                 }
655                 else {
656
657                     # date
658                     local (
659                         $path_info_yr, $path_info_mo,
660                         $path_info_da, $path_info
661                     ) = split /\//, $p, 4;
662                     unless ( defined $path_info ) { $path_info = "" }
663                     print $fh_w &generate( 'static', '', $p, $flavour,
664                         $content_type );
665                 }
666                 $fh_w->close;
667             }
668         }
669     }
670 }
671
672 # Dynamic
673 else {
674     $content_type = ( &$template( $path_info, 'content_type', $flavour ) );
675     $content_type =~ s!\n.*!!s;
676
677     $content_type =~ s/(\$\w+(?:::\w+)*)/"defined $1 ? $1 : ''"/gee;
678     $header = { -type => $content_type };
679
680     print generate( 'dynamic', $path_info,
681         "$path_info_yr/$path_info_mo_num/$path_info_da",
682         $flavour, $content_type );
683 }
684
685 # Plugins: End
686 foreach my $plugin (@plugins) {
687     if ( $plugins{$plugin} > 0 and $plugin->can('end') ) {
688         $entries = $plugin->end();
689     }
690 }
691
692 # Generate
693 sub generate {
694     my ( $static_or_dynamic, $currentdir, $date, $flavour, $content_type )
695         = @_;
696
697     %files = %$files;
698     %others = ref $others ? %$others : ();
699
700     # Plugins: Filter
701     foreach my $plugin (@plugins) {
702         if ( $plugins{$plugin} > 0 and $plugin->can('filter') ) {
703             $entries = $plugin->filter( \%files, \%others );
704         }
705     }
706
707     my %f = %files;
708
709     # Plugins: Skip
710     # Allow plugins to decide if we can cut short story generation
711     my $skip;
712     foreach my $plugin (@plugins) {
713         if ( $plugins{$plugin} > 0 and $plugin->can('skip') ) {
714             if ( my $tmp = $plugin->skip() ) {
715                 $skip = $tmp;
716                 last;
717             }
718         }
719     }
720
721     # Define default interpolation subroutine
722     $interpolate = sub {
723
724         package blosxom;
725         my $template = shift;
726
727         # Interpolate scalars, namespaced scalars, and hash/hashref scalars
728         $template
729             =~ s/(\$\w+(?:::\w+)*(?:(?:->)?{([\'\"]?)[-\w]+\2})?)/"defined $1 ? $1 : ''"/gee;
730         return $template;
731     };
732
733     unless ( defined($skip) and $skip ) {
734
735         # Plugins: Interpolate
736         # Allow for the first encountered plugin::interpolate subroutine to
737         # override the default built-in interpolate subroutine
738         foreach my $plugin (@plugins) {
739             if ( $plugins{$plugin} > 0 and $plugin->can('interpolate') ) {
740                 if ( my $tmp = $plugin->interpolate() ) {
741                     $interpolate = $tmp;
742                     last;
743                 }
744             }
745         }
746
747         # Head
748         my $head = ( &$template( $currentdir, 'head', $flavour ) );
749
750         # Plugins: Head
751         foreach my $plugin (@plugins) {
752             if ( $plugins{$plugin} > 0 and $plugin->can('head') ) {
753                 $entries = $plugin->head( $currentdir, \$head );
754             }
755         }
756
757         $head = &$interpolate($head);
758
759         $output .= $head;
760
761         # Stories
762         my $curdate = '';
763         my $ne      = $num_entries;
764
765         if ( $currentdir =~ /(.*?)([^\/]+)\.(.+)$/ and $2 ne 'index' ) {
766             $currentdir = "$1$2.$file_extension";
767             %f = ( "$datadir/$currentdir" => $files{"$datadir/$currentdir"} )
768                 if $files{"$datadir/$currentdir"};
769         }
770         else {
771             $currentdir =~ s!/index\..+$!!;
772         }
773
774         # Define a default sort subroutine
775         my $sort = sub {
776             my ($files_ref) = @_;
777             return sort { $files_ref->{$b} <=> $files_ref->{$a} }
778                 keys %$files_ref;
779         };
780
781      # Plugins: Sort
782      # Allow for the first encountered plugin::sort subroutine to override the
783      # default built-in sort subroutine
784         foreach my $plugin (@plugins) {
785             if ( $plugins{$plugin} > 0 and $plugin->can('sort') ) {
786                 if ( my $tmp = $plugin->sort() ) {
787                     $sort = $tmp;
788                     last;
789                 }
790             }
791         }
792
793         foreach my $path_file ( &$sort( \%f, \%others ) ) {
794             last if $ne <= 0 && $date !~ /\d/;
795             use vars qw/ $path $fn /;
796             ( $path, $fn )
797                 = $path_file =~ m!^$datadir/(?:(.*)/)?(.*)\.$file_extension!;
798
799             # Only stories in the right hierarchy
800             $path =~ /^$currentdir/
801                 or $path_file eq "$datadir/$currentdir"
802                 or next;
803
804             # Prepend a slash for use in templates only if a path exists
805             $path &&= "/$path";
806
807             # Date fiddling for by-{year,month,day} archive views
808             use vars
809                 qw/ $dw $mo $mo_num $da $ti $yr $hr $min $hr12 $ampm $utc_offset/;
810             ( $dw, $mo, $mo_num, $da, $ti, $yr, $utc_offset )
811                 = nice_date( $files{"$path_file"} );
812             ( $hr, $min ) = split /:/, $ti;
813             ( $hr12, $ampm ) = $hr >= 12 ? ( $hr - 12, 'pm' ) : ( $hr, 'am' );
814             $hr12 =~ s/^0//;
815             if ( $hr12 == 0 ) { $hr12 = 12 }
816
817             # Only stories from the right date
818             my ( $path_info_yr, $path_info_mo_num, $path_info_da )
819                 = split /\//, $date;
820             next if $path_info_yr     && $yr != $path_info_yr;
821             last if $path_info_yr     && $yr < $path_info_yr;
822             next if $path_info_mo_num && $mo ne $num2month[$path_info_mo_num];
823             next if $path_info_da     && $da != $path_info_da;
824             last if $path_info_da     && $da < $path_info_da;
825
826             # Date
827             my $date = ( &$template( $path, 'date', $flavour ) );
828
829             # Plugins: Date
830             foreach my $plugin (@plugins) {
831                 if ( $plugins{$plugin} > 0 and $plugin->can('date') ) {
832                     $entries
833                         = $plugin->date( $currentdir, \$date,
834                         $files{$path_file}, $dw, $mo, $mo_num, $da, $ti,
835                         $yr );
836                 }
837             }
838
839             $date = &$interpolate($date);
840
841             if ( $date && $curdate ne $date ) {
842                 $curdate = $date;
843                 $output .= $date;
844             }
845
846             use vars qw/ $title $body $raw /;
847             if ( -f "$path_file" && $fh->open("< $path_file") ) {
848                 chomp( $title = <$fh> );
849                 chomp( $body = join '', <$fh> );
850                 $fh->close;
851                 $raw = "$title\n$body";
852             }
853             my $story = ( &$template( $path, 'story', $flavour ) );
854
855             # Plugins: Story
856             foreach my $plugin (@plugins) {
857                 if ( $plugins{$plugin} > 0 and $plugin->can('story') ) {
858                     $entries = $plugin->story( $path, $fn, \$story, \$title,
859                         \$body );
860                 }
861             }
862
863             # Save unescaped versions and allow them to be used in
864             # flavour templates.
865             use vars qw/$url_unesc $path_unesc $fn_unesc/;
866             $url_unesc  = $url;
867             $path_unesc = $path;
868             $fn_unesc   = $fn;
869
870             # Fix special characters in links inside XML content
871             if (   $encode_xml_entities
872                 && $content_type =~ m{\bxml\b}
873                 && $content_type !~ m{\bxhtml\b} )
874             {
875
876                 # Escape special characters inside the <link> container
877
878                 &url_escape_url_path_and_fn();
879
880                 # Escape <, >, and &, and to produce valid RSS
881                 $title = blosxom_html_escape($title);
882                 $body  = blosxom_html_escape($body);
883                 $url   = blosxom_html_escape($url);
884                 $path  = blosxom_html_escape($path);
885                 $fn    = blosxom_html_escape($fn);
886             }
887
888             # Fix special characters in links inside XML content
889             if ($encode_8bit_chars) {
890                 &url_escape_url_path_and_fn();
891             }
892
893             $story = &$interpolate($story);
894
895             $output .= $story;
896             $fh->close;
897
898             $ne--;
899         }
900
901         # Foot
902         my $foot = ( &$template( $currentdir, 'foot', $flavour ) );
903
904         # Plugins: Foot
905         foreach my $plugin (@plugins) {
906             if ( $plugins{$plugin} > 0 and $plugin->can('foot') ) {
907                 $entries = $plugin->foot( $currentdir, \$foot );
908             }
909         }
910
911         $foot = &$interpolate($foot);
912         $output .= $foot;
913
914         # Plugins: Last
915         foreach my $plugin (@plugins) {
916             if ( $plugins{$plugin} > 0 and $plugin->can('last') ) {
917                 $entries = $plugin->last();
918             }
919         }
920
921     }    # End skip
922
923     # Finally, add the header, if any and running dynamically
924     $output = header($header) . $output
925         if ( $static_or_dynamic eq 'dynamic' and $header );
926
927     $output;
928 }
929
930 sub nice_date {
931     my ($unixtime) = @_;
932
933     my $c_time = CORE::localtime($unixtime);
934     my ( $dw, $mo, $da, $hr, $min, $sec, $yr )
935         = ( $c_time
936             =~ /(\w{3}) +(\w{3}) +(\d{1,2}) +(\d{2}):(\d{2}):(\d{2}) +(\d{4})$/
937         );
938     $ti = "$hr:$min";
939     $da = sprintf( "%02d", $da );
940     my $mo_num = $month2num{$mo};
941
942     my $offset
943         = timegm( $sec, $min, $hr, $da, $mo_num - 1, $yr - 1900 ) - $unixtime;
944     my $utc_offset = sprintf( "%+03d", int( $offset / 3600 ) )
945         . sprintf( "%02d", ( $offset % 3600 ) / 60 );
946
947     return ( $dw, $mo, $mo_num, $da, $ti, $yr, $utc_offset );
948 }
949
950 sub url_escape_url_path_and_fn {
951     $url  =~ s($url_escape_re)(sprintf('%%%02X', ord($&)))eg;
952     $path =~ s($url_escape_re)(sprintf('%%%02X', ord($&)))eg;
953     $fn   =~ s($url_escape_re)(sprintf('%%%02X', ord($&)))eg;
954 }
955
956 # Default HTML and RSS template bits
957 __DATA__
958 html content_type text/html; charset=$blog_encoding
959
960 html head <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
961 html head <html>
962 html head     <head>
963 html head         <meta http-equiv="content-type" content="$content_type" >
964 html head         <link rel="alternate" type="application/rss+xml" title="RSS" href="$url/index.rss" >
965 html head         <title>$blog_title $path_info_da $path_info_mo $path_info_yr</title>
966 html head     </head>
967 html head     <body>
968 html head         <div align="center">
969 html head             <h1>$blog_title</h1>
970 html head             <p>$path_info_da $path_info_mo $path_info_yr</p>
971 html head         </div>
972
973 html story         <div>
974 html story             <h3><a name="$fn">$title</a></h3>
975 html story             <div>$body</div>
976 html story             <p>posted at: $ti | path: <a href="$url$path">$path</a> | <a href="$url/$yr/$mo_num/$da#$fn">permanent link to this entry</a></p>
977 html story         </div>
978
979 html date         <h2>$dw, $da $mo $yr</h2>
980
981 html foot
982 html foot         <div align="center">
983 html foot             <a href="http://blosxom.sourceforge.net/"><img src="http://blosxom.sourceforge.net/images/pb_blosxom.gif" alt="powered by blosxom" border="0" width="90" height="33" ></a>
984 html foot         </div>
985 html foot     </body>
986 html foot </html>
987
988 rss content_type text/xml; charset=$blog_encoding
989
990 rss head <?xml version="1.0" encoding="$blog_encoding"?>
991 rss head <rss version="2.0">
992 rss head   <channel>
993 rss head     <title>$blog_title</title>
994 rss head     <link>$url/$path_info</link>
995 rss head     <description>$blog_description</description>
996 rss head     <language>$blog_language</language>
997 rss head     <docs>http://blogs.law.harvard.edu/tech/rss</docs>
998 rss head     <generator>blosxom/$version</generator>
999
1000 rss story   <item>
1001 rss story     <title>$title</title>
1002 rss story     <pubDate>$dw, $da $mo $yr $ti:00 $utc_offset</pubDate>
1003 rss story     <link>$url/$yr/$mo_num/$da#$fn</link>
1004 rss story     <category>$path</category>
1005 rss story     <guid isPermaLink="false">$url$path/$fn</guid>
1006 rss story     <description>$body</description>
1007 rss story   </item>
1008
1009 rss date 
1010
1011 rss foot   </channel>
1012 rss foot </rss>
1013
1014 error content_type text/html
1015
1016 error head <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
1017 error head <html>
1018 error head <head><title>Error: unknown Blosxom flavour "$flavour"</title></head>
1019 error head     <body>
1020 error head         <h1><font color="red">Error: unknown Blosxom flavour "$flavour"</font></h1>
1021 error head         <p>I'm afraid this is the first I've heard of a "$flavour" flavoured Blosxom.  Try dropping the "/+$flavour" bit from the end of the URL.</p>
1022
1023 error story        <h3>$title</h3>
1024 error story        <div>$body</div> <p><a href="$url/$yr/$mo_num/$da#fn.$default_flavour">#</a></p>
1025
1026 error date         <h2>$dw, $da $mo $yr</h2>
1027
1028 error foot     </body>
1029 error foot </html>
1030 __END__
1031