change calling convention for preprocessor functions
[ikiwiki] / 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                         return "[[$command $params]]";
143                 }
144                 elsif (exists $commands{$command}) {
145                         my %params;
146                         while ($params =~ /(\w+)=\"([^"]+)"(\s+|$)/g) {
147                                 $params{$1}=$2;
148                         }
149                         return $commands{$command}->(page => $page, %params);
150                 }
151                 else {
152                         return "[[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 %params=@_;
192         
193         if (! exists $params{pages}) {
194                 return "";
195         }
196         if (! exists $params{archive}) {
197                 $params{archive}="no";
198         }
199         if (! exists $params{show} && $params{archive} eq "no") {
200                 $params{show}=10;
201         }
202         if (! exists $depends{$params{page}}) {
203                 $depends{$params{page}}=$params{pages};
204         }
205         else {
206                 $depends{$params{page}}.=" ".$params{pages};
207         }
208
209         my $ret="";
210         
211         if (exists $params{rootpage}) {
212                 # Add a blog post form, with a rss link button.
213                 my $formtemplate=HTML::Template->new(blind_cache => 1,
214                         filename => "$config{templatedir}/blogpost.tmpl");
215                 $formtemplate->param(cgiurl => $config{cgiurl});
216                 $formtemplate->param(rootpage => $params{rootpage});
217                 if ($config{rss}) {
218                         $formtemplate->param(rssurl => rsspage(basename($params{page})));
219                 }
220                 $ret.=$formtemplate->output;
221         }
222         elsif ($config{rss}) {
223                 # Add a rss link button.
224                 my $linktemplate=HTML::Template->new(blind_cache => 1,
225                         filename => "$config{templatedir}/rsslink.tmpl");
226                 $linktemplate->param(rssurl => rsspage(basename($params{page})));
227                 $ret.=$linktemplate->output;
228         }
229         
230         my $template=HTML::Template->new(blind_cache => 1,
231                 filename => (($params{archive} eq "no") 
232                                 ? "$config{templatedir}/inlinepage.tmpl"
233                                 : "$config{templatedir}/inlinepagetitle.tmpl"));
234         
235         my @pages;
236         foreach my $page (blog_list($params{pages}, $params{show})) {
237                 next if $page eq $params{page};
238                 push @pages, $page;
239                 $template->param(pagelink => htmllink($params{page}, $page));
240                 $template->param(content => get_inline_content($params{page}, $page))
241                         if $params{archive} eq "no";
242                 $template->param(ctime => scalar(gmtime($pagectime{$page})));
243                 $ret.=$template->output;
244         }
245         
246         # TODO: should really add this to renderedfiles and call
247         # check_overwrite, but currently renderedfiles
248         # only supports listing one file per page.
249         if ($config{rss}) {
250                 writefile(rsspage($params{page}), $config{destdir},
251                         genrss($params{page}, @pages));
252         }
253         
254         return $ret;
255 } #}}}
256
257 sub genpage ($$$) { #{{{
258         my $content=shift;
259         my $page=shift;
260         my $mtime=shift;
261
262         my $title=pagetitle(basename($page));
263         
264         my $template=HTML::Template->new(blind_cache => 1,
265                 filename => "$config{templatedir}/page.tmpl");
266         
267         if (length $config{cgiurl}) {
268                 $template->param(editurl => cgiurl(do => "edit", page => $page));
269                 $template->param(prefsurl => cgiurl(do => "prefs"));
270                 if ($config{rcs}) {
271                         $template->param(recentchangesurl => cgiurl(do => "recentchanges"));
272                 }
273         }
274
275         if (length $config{historyurl}) {
276                 my $u=$config{historyurl};
277                 $u=~s/\[\[file\]\]/$pagesources{$page}/g;
278                 $template->param(historyurl => $u);
279         }
280         if ($config{hyperestraier}) {
281                 $template->param(hyperestraierurl => cgiurl());
282         }
283
284         $template->param(
285                 title => $title,
286                 wikiname => $config{wikiname},
287                 parentlinks => [parentlinks($page)],
288                 content => $content,
289                 backlinks => [backlinks($page)],
290                 discussionlink => htmllink($page, "Discussion", 1, 1),
291                 mtime => scalar(gmtime($mtime)),
292                 styleurl => styleurl($page),
293         );
294         
295         return $template->output;
296 } #}}}
297
298 sub date_822 ($) { #{{{
299         my $time=shift;
300
301         eval q{use POSIX};
302         return POSIX::strftime("%a, %d %b %Y %H:%M:%S %z", localtime($time));
303 } #}}}
304
305 sub absolute_urls ($$) { #{{{
306         # sucky sub because rss sucks
307         my $content=shift;
308         my $url=shift;
309
310         $url=~s/[^\/]+$//;
311         
312         $content=~s/<a\s+href="(?!http:\/\/)([^"]+)"/<a href="$url$1"/ig;
313         $content=~s/<img\s+src="(?!http:\/\/)([^"]+)"/<img src="$url$1"/ig;
314         return $content;
315 } #}}}
316
317 sub genrss ($@) { #{{{
318         my $page=shift;
319         my @pages=@_;
320         
321         my $url="$config{url}/".htmlpage($page);
322         
323         my $template=HTML::Template->new(blind_cache => 1,
324                 filename => "$config{templatedir}/rsspage.tmpl");
325         
326         my @items;
327         foreach my $p (@pages) {
328                 push @items, {
329                         itemtitle => pagetitle(basename($p)),
330                         itemurl => "$config{url}/$renderedfiles{$p}",
331                         itempubdate => date_822($pagectime{$p}),
332                         itemcontent => absolute_urls(get_inline_content($page, $p), $url),
333                 } if exists $renderedfiles{$p};
334         }
335
336         $template->param(
337                 title => $config{wikiname},
338                 pageurl => $url,
339                 items => \@items,
340         );
341         
342         return $template->output;
343 } #}}}
344
345 sub check_overwrite ($$) { #{{{
346         # Important security check. Make sure to call this before saving
347         # any files to the source directory.
348         my $dest=shift;
349         my $src=shift;
350         
351         if (! exists $renderedfiles{$src} && -e $dest && ! $config{rebuild}) {
352                 error("$dest already exists and was rendered from ".
353                         join(" ",(grep { $renderedfiles{$_} eq $dest } keys
354                                 %renderedfiles)).
355                         ", before, so not rendering from $src");
356         }
357 } #}}}
358
359 sub mtime ($) { #{{{
360         my $file=shift;
361         
362         return (stat($file))[9];
363 } #}}}
364
365 sub findlinks ($$) { #{{{
366         my $content=shift;
367         my $page=shift;
368
369         my @links;
370         while ($content =~ /(?<!\\)$config{wiki_link_regexp}/g) {
371                 push @links, titlepage($2);
372         }
373         # Discussion links are a special case since they're not in the text
374         # of the page, but on its template.
375         return @links, "$page/discussion";
376 } #}}}
377
378 sub render ($) { #{{{
379         my $file=shift;
380         
381         my $type=pagetype($file);
382         my $srcfile=srcfile($file);
383         if ($type ne 'unknown') {
384                 my $content=readfile($srcfile);
385                 my $page=pagename($file);
386                 
387                 $links{$page}=[findlinks($content, $page)];
388                 delete $depends{$page};
389                 
390                 $content=linkify($content, $page);
391                 $content=preprocess($page, $content);
392                 $content=htmlize($type, $content);
393                 
394                 check_overwrite("$config{destdir}/".htmlpage($page), $page);
395                 writefile(htmlpage($page), $config{destdir},
396                         genpage($content, $page, mtime($srcfile)));
397                 $oldpagemtime{$page}=time;
398                 $renderedfiles{$page}=htmlpage($page);
399         }
400         else {
401                 my $content=readfile($srcfile, 1);
402                 $links{$file}=[];
403                 check_overwrite("$config{destdir}/$file", $file);
404                 writefile($file, $config{destdir}, $content, 1);
405                 $oldpagemtime{$file}=time;
406                 $renderedfiles{$file}=$file;
407         }
408 } #}}}
409
410 sub prune ($) { #{{{
411         my $file=shift;
412
413         unlink($file);
414         my $dir=dirname($file);
415         while (rmdir($dir)) {
416                 $dir=dirname($dir);
417         }
418 } #}}}
419
420 sub estcfg () { #{{{
421         my $estdir="$config{wikistatedir}/hyperestraier";
422         my $cgi=basename($config{cgiurl});
423         $cgi=~s/\..*$//;
424         open(TEMPLATE, ">$estdir/$cgi.tmpl") ||
425                 error("write $estdir/$cgi.tmpl: $!");
426         print TEMPLATE misctemplate("search", 
427                 "<!--ESTFORM-->\n\n<!--ESTRESULT-->\n\n<!--ESTINFO-->\n\n");
428         close TEMPLATE;
429         open(TEMPLATE, ">$estdir/$cgi.conf") ||
430                 error("write $estdir/$cgi.conf: $!");
431         my $template=HTML::Template->new(
432                 filename => "$config{templatedir}/estseek.conf"
433         );
434         eval q{use Cwd 'abs_path'};
435         $template->param(
436                 index => $estdir,
437                 tmplfile => "$estdir/$cgi.tmpl",
438                 destdir => abs_path($config{destdir}),
439                 url => $config{url},
440         );
441         print TEMPLATE $template->output;
442         close TEMPLATE;
443         $cgi="$estdir/".basename($config{cgiurl});
444         unlink($cgi);
445         symlink("/usr/lib/estraier/estseek.cgi", $cgi) ||
446                 error("symlink $cgi: $!");
447 } # }}}
448
449 sub estcmd ($;@) { #{{{
450         my @params=split(' ', shift);
451         push @params, "-cl", "$config{wikistatedir}/hyperestraier";
452         if (@_) {
453                 push @params, "-";
454         }
455         
456         my $pid=open(CHILD, "|-");
457         if ($pid) {
458                 # parent
459                 foreach (@_) {
460                         print CHILD "$_\n";
461                 }
462                 close(CHILD) || error("estcmd @params exited nonzero: $?");
463         }
464         else {
465                 # child
466                 open(STDOUT, "/dev/null"); # shut it up (closing won't work)
467                 exec("estcmd", @params) || error("can't run estcmd");
468         }
469 } #}}}
470
471 sub refresh () { #{{{
472         # find existing pages
473         my %exists;
474         my @files;
475         eval q{use File::Find};
476         find({
477                 no_chdir => 1,
478                 wanted => sub {
479                         if (/$config{wiki_file_prune_regexp}/) {
480                                 $File::Find::prune=1;
481                         }
482                         elsif (! -d $_ && ! -l $_) {
483                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
484                                 if (! defined $f) {
485                                         warn("skipping bad filename $_\n");
486                                 }
487                                 else {
488                                         $f=~s/^\Q$config{srcdir}\E\/?//;
489                                         push @files, $f;
490                                         $exists{pagename($f)}=1;
491                                 }
492                         }
493                 },
494         }, $config{srcdir});
495         find({
496                 no_chdir => 1,
497                 wanted => sub {
498                         if (/$config{wiki_file_prune_regexp}/) {
499                                 $File::Find::prune=1;
500                         }
501                         elsif (! -d $_ && ! -l $_) {
502                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
503                                 if (! defined $f) {
504                                         warn("skipping bad filename $_\n");
505                                 }
506                                 else {
507                                         # Don't add files that are in the
508                                         # srcdir.
509                                         $f=~s/^\Q$config{underlaydir}\E\/?//;
510                                         if (! -e "$config{srcdir}/$f" && 
511                                             ! -l "$config{srcdir}/$f") {
512                                                 push @files, $f;
513                                                 $exists{pagename($f)}=1;
514                                         }
515                                 }
516                         }
517                 },
518         }, $config{underlaydir});
519
520         my %rendered;
521
522         # check for added or removed pages
523         my @add;
524         foreach my $file (@files) {
525                 my $page=pagename($file);
526                 if (! $oldpagemtime{$page}) {
527                         debug("new page $page") unless exists $pagectime{$page};
528                         push @add, $file;
529                         $links{$page}=[];
530                         $pagesources{$page}=$file;
531                         $pagectime{$page}=mtime(srcfile($file))
532                                 unless exists $pagectime{$page};
533                 }
534         }
535         my @del;
536         foreach my $page (keys %oldpagemtime) {
537                 if (! $exists{$page}) {
538                         debug("removing old page $page");
539                         push @del, $pagesources{$page};
540                         prune($config{destdir}."/".$renderedfiles{$page});
541                         delete $renderedfiles{$page};
542                         $oldpagemtime{$page}=0;
543                         delete $pagesources{$page};
544                 }
545         }
546         
547         # render any updated files
548         foreach my $file (@files) {
549                 my $page=pagename($file);
550                 
551                 if (! exists $oldpagemtime{$page} ||
552                     mtime(srcfile($file)) > $oldpagemtime{$page}) {
553                         debug("rendering changed file $file");
554                         render($file);
555                         $rendered{$file}=1;
556                 }
557         }
558         
559         # if any files were added or removed, check to see if each page
560         # needs an update due to linking to them or inlining them.
561         # TODO: inefficient; pages may get rendered above and again here;
562         # problem is the bestlink may have changed and we won't know until
563         # now
564         if (@add || @del) {
565 FILE:           foreach my $file (@files) {
566                         my $page=pagename($file);
567                         foreach my $f (@add, @del) {
568                                 my $p=pagename($f);
569                                 foreach my $link (@{$links{$page}}) {
570                                         if (bestlink($page, $link) eq $p) {
571                                                 debug("rendering $file, which links to $p");
572                                                 render($file);
573                                                 $rendered{$file}=1;
574                                                 next FILE;
575                                         }
576                                 }
577                         }
578                 }
579         }
580
581         # Handle backlinks; if a page has added/removed links, update the
582         # pages it links to. Also handles rebuilding dependat pages.
583         # TODO: inefficient; pages may get rendered above and again here;
584         # problem is the backlinks could be wrong in the first pass render
585         # above
586         if (%rendered || @del) {
587                 foreach my $f (@files) {
588                         my $p=pagename($f);
589                         if (exists $depends{$p}) {
590                                 foreach my $file (keys %rendered, @del) {
591                                         my $page=pagename($file);
592                                         if (globlist_match($page, $depends{$p})) {
593                                                 debug("rendering $f, which depends on $page");
594                                                 render($f);
595                                                 $rendered{$f}=1;
596                                                 last;
597                                         }
598                                 }
599                         }
600                 }
601                 
602                 my %linkchanged;
603                 foreach my $file (keys %rendered, @del) {
604                         my $page=pagename($file);
605                         
606                         if (exists $links{$page}) {
607                                 foreach my $link (map { bestlink($page, $_) } @{$links{$page}}) {
608                                         if (length $link &&
609                                             ! exists $oldlinks{$page} ||
610                                             ! grep { $_ eq $link } @{$oldlinks{$page}}) {
611                                                 $linkchanged{$link}=1;
612                                         }
613                                 }
614                         }
615                         if (exists $oldlinks{$page}) {
616                                 foreach my $link (map { bestlink($page, $_) } @{$oldlinks{$page}}) {
617                                         if (length $link &&
618                                             ! exists $links{$page} ||
619                                             ! grep { $_ eq $link } @{$links{$page}}) {
620                                                 $linkchanged{$link}=1;
621                                         }
622                                 }
623                         }
624                 }
625                 foreach my $link (keys %linkchanged) {
626                         my $linkfile=$pagesources{$link};
627                         if (defined $linkfile) {
628                                 debug("rendering $linkfile, to update its backlinks");
629                                 render($linkfile);
630                                 $rendered{$linkfile}=1;
631                         }
632                 }
633         }
634
635         if ($config{hyperestraier} && (%rendered || @del)) {
636                 debug("updating hyperestraier search index");
637                 if (%rendered) {
638                         estcmd("gather -cm -bc -cl -sd", 
639                                 map { $config{destdir}."/".$renderedfiles{pagename($_)} }
640                                 keys %rendered);
641                 }
642                 if (@del) {
643                         estcmd("purge -cl");
644                 }
645                 
646                 debug("generating hyperestraier cgi config");
647                 estcfg();
648         }
649 } #}}}
650
651 1