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