web commit by joey
[ikiwiki.git] / IkiWiki / Render.pm
1 #!/usr/bin/perl
2
3 package IkiWiki;
4
5 use warnings;
6 use strict;
7 use File::Spec;
8
9 sub linkify ($$) { #{{{
10         my $content=shift;
11         my $page=shift;
12
13         $content =~ s{(\\?)$config{wiki_link_regexp}}{
14                 $2 ? ( $1 ? "[[$2|$3]]" : htmllink($page, titlepage($3), 0, 0, pagetitle($2)))
15                    : ( $1 ? "[[$3]]" :    htmllink($page, titlepage($3)))
16         }eg;
17         
18         return $content;
19 } #}}}
20
21 my $_scrubber;
22 sub scrubber { #{{{
23         return $_scrubber if defined $_scrubber;
24         
25         eval q{use HTML::Scrubber};
26         # Lists based on http://feedparser.org/docs/html-sanitization.html
27         $_scrubber = HTML::Scrubber->new(
28                 allow => [qw{
29                         a abbr acronym address area b big blockquote br
30                         button caption center cite code col colgroup dd del
31                         dfn dir div dl dt em fieldset font form h1 h2 h3 h4
32                         h5 h6 hr i img input ins kbd label legend li map
33                         menu ol optgroup option p pre q s samp select small
34                         span strike strong sub sup table tbody td textarea
35                         tfoot th thead tr tt u ul var
36                 }],
37                 default => [undef, { map { $_ => 1 } qw{
38                         abbr accept accept-charset accesskey action
39                         align alt axis border cellpadding cellspacing
40                         char charoff charset checked cite class
41                         clear cols colspan color compact coords
42                         datetime dir disabled enctype for frame
43                         headers height href hreflang hspace id ismap
44                         label lang longdesc maxlength media method
45                         multiple name nohref noshade nowrap prompt
46                         readonly rel rev rows rowspan rules scope
47                         selected shape size span src start summary
48                         tabindex target title type usemap valign
49                         value vspace width
50                 }}],
51         );
52         return $_scrubber;
53 } # }}}
54
55 sub htmlize ($$) { #{{{
56         my $type=shift;
57         my $content=shift;
58         
59         if (! $INC{"/usr/bin/markdown"}) {
60                 no warnings 'once';
61                 $blosxom::version="is a proper perl module too much to ask?";
62                 use warnings 'all';
63                 do "/usr/bin/markdown";
64         }
65         
66         if ($type eq '.mdwn') {
67                 $content=Markdown::Markdown($content);
68         }
69         else {
70                 error("htmlization of $type not supported");
71         }
72
73         if ($config{sanitize}) {
74                 $content=scrubber()->scrub($content);
75         }
76         
77         return $content;
78 } #}}}
79
80 sub backlinks ($) { #{{{
81         my $page=shift;
82
83         my @links;
84         foreach my $p (keys %links) {
85                 next if bestlink($page, $p) eq $page;
86                 if (grep { length $_ && bestlink($p, $_) eq $page } @{$links{$p}}) {
87                         my $href=File::Spec->abs2rel(htmlpage($p), dirname($page));
88                         
89                         # Trim common dir prefixes from both pages.
90                         my $p_trimmed=$p;
91                         my $page_trimmed=$page;
92                         my $dir;
93                         1 while (($dir)=$page_trimmed=~m!^([^/]+/)!) &&
94                                 defined $dir &&
95                                 $p_trimmed=~s/^\Q$dir\E// &&
96                                 $page_trimmed=~s/^\Q$dir\E//;
97                                        
98                         push @links, { url => $href, page => $p_trimmed };
99                 }
100         }
101
102         return sort { $a->{page} cmp $b->{page} } @links;
103 } #}}}
104
105 sub parentlinks ($) { #{{{
106         my $page=shift;
107         
108         my @ret;
109         my $pagelink="";
110         my $path="";
111         my $skip=1;
112         foreach my $dir (reverse split("/", $page)) {
113                 if (! $skip) {
114                         $path.="../";
115                         unshift @ret, { url => "$path$dir.html", page => $dir };
116                 }
117                 else {
118                         $skip=0;
119                 }
120         }
121         unshift @ret, { url => length $path ? $path : ".", page => $config{wikiname} };
122         return @ret;
123 } #}}}
124
125 sub rsspage ($) { #{{{
126         my $page=shift;
127
128         return $page.".rss";
129 } #}}}
130
131 sub preprocess ($$) { #{{{
132         my $page=shift;
133         my $content=shift;
134
135         my %commands=(inline => \&preprocess_inline);
136         
137         my $handle=sub {
138                 my $escape=shift;
139                 my $command=shift;
140                 my $params=shift;
141                 if (length $escape) {
142                         "[[$command $params]]";
143                 }
144                 elsif (exists $commands{$command}) {
145                         my %params;
146                         while ($params =~ /(\w+)=\"([^"]+)"(\s+|$)/g) {
147                                 $params{$1}=$2;
148                         }
149                         $commands{$command}->($page, %params);
150                 }
151                 else {
152                         "[[bad directive $command]]";
153                 }
154         };
155         
156         $content =~ s{(\\?)$config{wiki_processor_regexp}}{$handle->($1, $2, $3)}eg;
157         return $content;
158 } #}}}
159
160 sub blog_list ($$) { #{{{
161         my $globlist=shift;
162         my $maxitems=shift;
163         
164         my @list;
165         foreach my $page (keys %pagesources) {
166                 if (globlist_match($page, $globlist)) {
167                         push @list, $page;
168                 }
169         }
170
171         @list=sort { $pagectime{$b} <=> $pagectime{$a} } @list;
172         return @list if ! $maxitems || @list <= $maxitems;
173         return @list[0..$maxitems - 1];
174 } #}}}
175
176 sub get_inline_content ($$) { #{{{
177         my $parentpage=shift;
178         my $page=shift;
179         
180         my $file=$pagesources{$page};
181         my $type=pagetype($file);
182         if ($type ne 'unknown') {
183                 return htmlize($type, linkify(readfile(srcfile($file)), $parentpage));
184         }
185         else {
186                 return "";
187         }
188 } #}}}
189
190 sub preprocess_inline ($@) { #{{{
191         my $parentpage=shift;
192         my %params=@_;
193         
194         if (! exists $params{pages}) {
195                 return "";
196         }
197         if (! exists $params{archive}) {
198                 $params{archive}="no";
199         }
200         if (! exists $params{show} && $params{archive} eq "no") {
201                 $params{show}=10;
202         }
203         $inlinepages{$parentpage}=$params{pages};
204
205         my $ret="";
206         
207         if (exists $params{rootpage}) {
208                 my $formtemplate=HTML::Template->new(blind_cache => 1,
209                         filename => "$config{templatedir}/blogpost.tmpl");
210                 $formtemplate->param(cgiurl => $config{cgiurl});
211                 $formtemplate->param(rootpage => $params{rootpage});
212                 my $form=$formtemplate->output;
213                 $ret.=$form;
214         }
215         
216         my $template=HTML::Template->new(blind_cache => 1,
217                 filename => (($params{archive} eq "no") 
218                                 ? "$config{templatedir}/inlinepage.tmpl"
219                                 : "$config{templatedir}/inlinepagetitle.tmpl"));
220         
221         my @pages;
222         foreach my $page (blog_list($params{pages}, $params{show})) {
223                 next if $page eq $parentpage;
224                 push @pages, $page;
225                 $template->param(pagelink => htmllink($parentpage, $page));
226                 $template->param(content => get_inline_content($parentpage, $page))
227                         if $params{archive} eq "no";
228                 $template->param(ctime => scalar(gmtime($pagectime{$page})));
229                 $ret.=$template->output;
230         }
231         
232         # TODO: should really add this to renderedfiles and call
233         # check_overwrite, but currently renderedfiles
234         # only supports listing one file per page.
235         if ($config{rss}) {
236                 writefile(rsspage($parentpage), $config{destdir},
237                         genrss($parentpage, @pages));
238         }
239         
240         return $ret;
241 } #}}}
242
243 sub genpage ($$$) { #{{{
244         my $content=shift;
245         my $page=shift;
246         my $mtime=shift;
247
248         my $title=pagetitle(basename($page));
249         
250         my $template=HTML::Template->new(blind_cache => 1,
251                 filename => "$config{templatedir}/page.tmpl");
252         
253         if (length $config{cgiurl}) {
254                 $template->param(editurl => cgiurl(do => "edit", page => $page));
255                 $template->param(prefsurl => cgiurl(do => "prefs"));
256                 if ($config{rcs}) {
257                         $template->param(recentchangesurl => cgiurl(do => "recentchanges"));
258                 }
259         }
260
261         if (length $config{historyurl}) {
262                 my $u=$config{historyurl};
263                 $u=~s/\[\[file\]\]/$pagesources{$page}/g;
264                 $template->param(historyurl => $u);
265         }
266         if ($config{hyperestraier}) {
267                 $template->param(hyperestraierurl => cgiurl());
268         }
269
270         if ($config{rss} && $inlinepages{$page}) {
271                 $template->param(rssurl => rsspage(basename($page)));
272         }
273         
274         $template->param(
275                 title => $title,
276                 wikiname => $config{wikiname},
277                 parentlinks => [parentlinks($page)],
278                 content => $content,
279                 backlinks => [backlinks($page)],
280                 discussionlink => htmllink($page, "Discussion", 1, 1),
281                 mtime => scalar(gmtime($mtime)),
282                 styleurl => styleurl($page),
283         );
284         
285         return $template->output;
286 } #}}}
287
288 sub date_822 ($) { #{{{
289         my $time=shift;
290
291         eval q{use POSIX};
292         return POSIX::strftime("%a, %d %b %Y %H:%M:%S %z", localtime($time));
293 } #}}}
294
295 sub absolute_urls ($$) { #{{{
296         # sucky sub because rss sucks
297         my $content=shift;
298         my $url=shift;
299
300         $url=~s/[^\/]+$//;
301         
302         $content=~s/<a\s+href="(?!http:\/\/)([^"]+)"/<a href="$url$1"/ig;
303         $content=~s/<img\s+src="(?!http:\/\/)([^"]+)"/<img src="$url$1"/ig;
304         return $content;
305 } #}}}
306
307 sub genrss ($@) { #{{{
308         my $page=shift;
309         my @pages=@_;
310         
311         my $url="$config{url}/".htmlpage($page);
312         
313         my $template=HTML::Template->new(blind_cache => 1,
314                 filename => "$config{templatedir}/rsspage.tmpl");
315         
316         my @items;
317         foreach my $p (@pages) {
318                 push @items, {
319                         itemtitle => pagetitle(basename($p)),
320                         itemurl => "$config{url}/$renderedfiles{$p}",
321                         itempubdate => date_822($pagectime{$p}),
322                         itemcontent => absolute_urls(get_inline_content($page, $p), $url),
323                 } if exists $renderedfiles{$p};
324         }
325
326         $template->param(
327                 title => $config{wikiname},
328                 pageurl => $url,
329                 items => \@items,
330         );
331         
332         return $template->output;
333 } #}}}
334
335 sub check_overwrite ($$) { #{{{
336         # Important security check. Make sure to call this before saving
337         # any files to the source directory.
338         my $dest=shift;
339         my $src=shift;
340         
341         if (! exists $renderedfiles{$src} && -e $dest && ! $config{rebuild}) {
342                 error("$dest already exists and was rendered from ".
343                         join(" ",(grep { $renderedfiles{$_} eq $dest } keys
344                                 %renderedfiles)).
345                         ", before, so not rendering from $src");
346         }
347 } #}}}
348
349 sub mtime ($) { #{{{
350         my $file=shift;
351         
352         return (stat($file))[9];
353 } #}}}
354
355 sub findlinks ($$) { #{{{
356         my $content=shift;
357         my $page=shift;
358
359         my @links;
360         while ($content =~ /(?<!\\)$config{wiki_link_regexp}/g) {
361                 push @links, titlepage($2);
362         }
363         # Discussion links are a special case since they're not in the text
364         # of the page, but on its template.
365         return @links, "$page/discussion";
366 } #}}}
367
368 sub render ($) { #{{{
369         my $file=shift;
370         
371         my $type=pagetype($file);
372         my $srcfile=srcfile($file);
373         if ($type ne 'unknown') {
374                 my $content=readfile($srcfile);
375                 my $page=pagename($file);
376                 
377                 $links{$page}=[findlinks($content, $page)];
378                 delete $inlinepages{$page};
379                 
380                 $content=linkify($content, $page);
381                 $content=preprocess($page, $content);
382                 $content=htmlize($type, $content);
383                 
384                 check_overwrite("$config{destdir}/".htmlpage($page), $page);
385                 writefile(htmlpage($page), $config{destdir},
386                         genpage($content, $page, mtime($srcfile)));
387                 $oldpagemtime{$page}=time;
388                 $renderedfiles{$page}=htmlpage($page);
389         }
390         else {
391                 my $content=readfile($srcfile, 1);
392                 $links{$file}=[];
393                 check_overwrite("$config{destdir}/$file", $file);
394                 writefile($file, $config{destdir}, $content, 1);
395                 $oldpagemtime{$file}=time;
396                 $renderedfiles{$file}=$file;
397         }
398 } #}}}
399
400 sub prune ($) { #{{{
401         my $file=shift;
402
403         unlink($file);
404         my $dir=dirname($file);
405         while (rmdir($dir)) {
406                 $dir=dirname($dir);
407         }
408 } #}}}
409
410 sub estcfg () { #{{{
411         my $estdir="$config{wikistatedir}/hyperestraier";
412         my $cgi=basename($config{cgiurl});
413         $cgi=~s/\..*$//;
414         open(TEMPLATE, ">$estdir/$cgi.tmpl") ||
415                 error("write $estdir/$cgi.tmpl: $!");
416         print TEMPLATE misctemplate("search", 
417                 "<!--ESTFORM-->\n\n<!--ESTRESULT-->\n\n<!--ESTINFO-->\n\n");
418         close TEMPLATE;
419         open(TEMPLATE, ">$estdir/$cgi.conf") ||
420                 error("write $estdir/$cgi.conf: $!");
421         my $template=HTML::Template->new(
422                 filename => "$config{templatedir}/estseek.conf"
423         );
424         eval q{use Cwd 'abs_path'};
425         $template->param(
426                 index => $estdir,
427                 tmplfile => "$estdir/$cgi.tmpl",
428                 destdir => abs_path($config{destdir}),
429                 url => $config{url},
430         );
431         print TEMPLATE $template->output;
432         close TEMPLATE;
433         $cgi="$estdir/".basename($config{cgiurl});
434         unlink($cgi);
435         symlink("/usr/lib/estraier/estseek.cgi", $cgi) ||
436                 error("symlink $cgi: $!");
437 } # }}}
438
439 sub estcmd ($;@) { #{{{
440         my @params=split(' ', shift);
441         push @params, "-cl", "$config{wikistatedir}/hyperestraier";
442         if (@_) {
443                 push @params, "-";
444         }
445         
446         my $pid=open(CHILD, "|-");
447         if ($pid) {
448                 # parent
449                 foreach (@_) {
450                         print CHILD "$_\n";
451                 }
452                 close(CHILD) || error("estcmd @params exited nonzero: $?");
453         }
454         else {
455                 # child
456                 open(STDOUT, "/dev/null"); # shut it up (closing won't work)
457                 exec("estcmd", @params) || error("can't run estcmd");
458         }
459 } #}}}
460
461 sub refresh () { #{{{
462         # find existing pages
463         my %exists;
464         my @files;
465         eval q{use File::Find};
466         find({
467                 no_chdir => 1,
468                 wanted => sub {
469                         if (/$config{wiki_file_prune_regexp}/) {
470                                 $File::Find::prune=1;
471                         }
472                         elsif (! -d $_ && ! -l $_) {
473                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
474                                 if (! defined $f) {
475                                         warn("skipping bad filename $_\n");
476                                 }
477                                 else {
478                                         $f=~s/^\Q$config{srcdir}\E\/?//;
479                                         push @files, $f;
480                                         $exists{pagename($f)}=1;
481                                 }
482                         }
483                 },
484         }, $config{srcdir});
485         find({
486                 no_chdir => 1,
487                 wanted => sub {
488                         if (/$config{wiki_file_prune_regexp}/) {
489                                 $File::Find::prune=1;
490                         }
491                         elsif (! -d $_ && ! -l $_) {
492                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
493                                 if (! defined $f) {
494                                         warn("skipping bad filename $_\n");
495                                 }
496                                 else {
497                                         # Don't add files that are in the
498                                         # srcdir.
499                                         $f=~s/^\Q$config{underlaydir}\E\/?//;
500                                         if (! -e "$config{srcdir}/$f" && 
501                                             ! -l "$config{srcdir}/$f") {
502                                                 push @files, $f;
503                                                 $exists{pagename($f)}=1;
504                                         }
505                                 }
506                         }
507                 },
508         }, $config{underlaydir});
509
510         my %rendered;
511
512         # check for added or removed pages
513         my @add;
514         foreach my $file (@files) {
515                 my $page=pagename($file);
516                 if (! $oldpagemtime{$page}) {
517                         debug("new page $page") unless exists $pagectime{$page};
518                         push @add, $file;
519                         $links{$page}=[];
520                         $pagesources{$page}=$file;
521                         $pagectime{$page}=mtime(srcfile($file))
522                                 unless exists $pagectime{$page};
523                 }
524         }
525         my @del;
526         foreach my $page (keys %oldpagemtime) {
527                 if (! $exists{$page}) {
528                         debug("removing old page $page");
529                         push @del, $pagesources{$page};
530                         prune($config{destdir}."/".$renderedfiles{$page});
531                         delete $renderedfiles{$page};
532                         $oldpagemtime{$page}=0;
533                         delete $pagesources{$page};
534                 }
535         }
536         
537         # render any updated files
538         foreach my $file (@files) {
539                 my $page=pagename($file);
540                 
541                 if (! exists $oldpagemtime{$page} ||
542                     mtime(srcfile($file)) > $oldpagemtime{$page}) {
543                         debug("rendering changed file $file");
544                         render($file);
545                         $rendered{$file}=1;
546                 }
547         }
548         
549         # if any files were added or removed, check to see if each page
550         # needs an update due to linking to them or inlining them.
551         # TODO: inefficient; pages may get rendered above and again here;
552         # problem is the bestlink may have changed and we won't know until
553         # now
554         if (@add || @del) {
555 FILE:           foreach my $file (@files) {
556                         my $page=pagename($file);
557                         foreach my $f (@add, @del) {
558                                 my $p=pagename($f);
559                                 foreach my $link (@{$links{$page}}) {
560                                         if (bestlink($page, $link) eq $p) {
561                                                 debug("rendering $file, which links to $p");
562                                                 render($file);
563                                                 $rendered{$file}=1;
564                                                 next FILE;
565                                         }
566                                 }
567                         }
568                 }
569         }
570
571         # Handle backlinks; if a page has added/removed links, update the
572         # pages it links to. Also handle inlining here.
573         # TODO: inefficient; pages may get rendered above and again here;
574         # problem is the backlinks could be wrong in the first pass render
575         # above
576         if (%rendered || @del) {
577                 foreach my $f (@files) {
578                         my $p=pagename($f);
579                         if (exists $inlinepages{$p}) {
580                                 foreach my $file (keys %rendered, @del) {
581                                         my $page=pagename($file);
582                                         if (globlist_match($page, $inlinepages{$p})) {
583                                                 debug("rendering $f, which inlines $page");
584                                                 render($f);
585                                                 $rendered{$f}=1;
586                                                 last;
587                                         }
588                                 }
589                         }
590                 }
591                 
592                 my %linkchanged;
593                 foreach my $file (keys %rendered, @del) {
594                         my $page=pagename($file);
595                         
596                         if (exists $links{$page}) {
597                                 foreach my $link (map { bestlink($page, $_) } @{$links{$page}}) {
598                                         if (length $link &&
599                                             ! exists $oldlinks{$page} ||
600                                             ! grep { $_ eq $link } @{$oldlinks{$page}}) {
601                                                 $linkchanged{$link}=1;
602                                         }
603                                 }
604                         }
605                         if (exists $oldlinks{$page}) {
606                                 foreach my $link (map { bestlink($page, $_) } @{$oldlinks{$page}}) {
607                                         if (length $link &&
608                                             ! exists $links{$page} ||
609                                             ! grep { $_ eq $link } @{$links{$page}}) {
610                                                 $linkchanged{$link}=1;
611                                         }
612                                 }
613                         }
614                 }
615                 foreach my $link (keys %linkchanged) {
616                         my $linkfile=$pagesources{$link};
617                         if (defined $linkfile) {
618                                 debug("rendering $linkfile, to update its backlinks");
619                                 render($linkfile);
620                                 $rendered{$linkfile}=1;
621                         }
622                 }
623         }
624
625         if ($config{hyperestraier} && (%rendered || @del)) {
626                 debug("updating hyperestraier search index");
627                 if (%rendered) {
628                         estcmd("gather -cm -bc -cl -sd", 
629                                 map { $config{destdir}."/".$renderedfiles{pagename($_)} }
630                                 keys %rendered);
631                 }
632                 if (@del) {
633                         estcmd("purge -cl");
634                 }
635                 
636                 debug("generating hyperestraier cgi config");
637                 estcfg();
638         }
639 } #}}}
640
641 1