* Split off an IkiWiki.pm out of ikiwiki and have all the other modules use
[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 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;
162         }
163 } # }}}
164
165 sub genpage ($$$) { #{{{
166         my $content=shift;
167         my $page=shift;
168         my $mtime=shift;
169
170         my $title=pagetitle(basename($page));
171         
172         my $template=HTML::Template->new(blind_cache => 1,
173                 filename => "$config{templatedir}/page.tmpl");
174         
175         if (length $config{cgiurl}) {
176                 $template->param(editurl => cgiurl(do => "edit", page => $page));
177                 $template->param(prefsurl => cgiurl(do => "prefs"));
178                 if ($config{rcs}) {
179                         $template->param(recentchangesurl => cgiurl(do => "recentchanges"));
180                 }
181         }
182
183         if (length $config{historyurl}) {
184                 my $u=$config{historyurl};
185                 $u=~s/\[\[file\]\]/$pagesources{$page}/g;
186                 $template->param(historyurl => $u);
187         }
188         if ($config{hyperestraier}) {
189                 $template->param(hyperestraierurl => cgiurl());
190         }
191
192         $template->param(
193                 title => $title,
194                 wikiname => $config{wikiname},
195                 parentlinks => [parentlinks($page)],
196                 content => $content,
197                 backlinks => [backlinks($page)],
198                 discussionlink => htmllink($page, "Discussion", 1, 1),
199                 mtime => scalar(gmtime($mtime)),
200                 styleurl => styleurl($page),
201         );
202         
203         return $template->output;
204 } #}}}
205
206 sub check_overwrite ($$) { #{{{
207         # Important security check. Make sure to call this before saving
208         # any files to the source directory.
209         my $dest=shift;
210         my $src=shift;
211         
212         if (! exists $renderedfiles{$src} && -e $dest && ! $config{rebuild}) {
213                 error("$dest already exists and was rendered from ".
214                         join(" ",(grep { $renderedfiles{$_} eq $dest } keys
215                                 %renderedfiles)).
216                         ", before, so not rendering from $src");
217         }
218 } #}}}
219
220 sub mtime ($) { #{{{
221         my $file=shift;
222         
223         return (stat($file))[9];
224 } #}}}
225
226 sub findlinks ($$) { #{{{
227         my $content=shift;
228         my $page=shift;
229
230         my @links;
231         while ($content =~ /(?<!\\)$config{wiki_link_regexp}/g) {
232                 push @links, titlepage($2);
233         }
234         # Discussion links are a special case since they're not in the text
235         # of the page, but on its template.
236         return @links, "$page/discussion";
237 } #}}}
238
239 sub render ($) { #{{{
240         my $file=shift;
241         
242         my $type=pagetype($file);
243         my $srcfile=srcfile($file);
244         if ($type ne 'unknown') {
245                 my $content=readfile($srcfile);
246                 my $page=pagename($file);
247                 
248                 $links{$page}=[findlinks($content, $page)];
249                 delete $depends{$page};
250                 
251                 $content=linkify($content, $page);
252                 $content=preprocess($page, $content);
253                 $content=htmlize($type, $content);
254                 
255                 check_overwrite("$config{destdir}/".htmlpage($page), $page);
256                 writefile(htmlpage($page), $config{destdir},
257                         genpage($content, $page, mtime($srcfile)));
258                 $oldpagemtime{$page}=time;
259                 $renderedfiles{$page}=htmlpage($page);
260         }
261         else {
262                 my $content=readfile($srcfile, 1);
263                 $links{$file}=[];
264                 delete $depends{$file};
265                 check_overwrite("$config{destdir}/$file", $file);
266                 writefile($file, $config{destdir}, $content, 1);
267                 $oldpagemtime{$file}=time;
268                 $renderedfiles{$file}=$file;
269         }
270 } #}}}
271
272 sub prune ($) { #{{{
273         my $file=shift;
274
275         unlink($file);
276         my $dir=dirname($file);
277         while (rmdir($dir)) {
278                 $dir=dirname($dir);
279         }
280 } #}}}
281
282 sub estcfg () { #{{{
283         my $estdir="$config{wikistatedir}/hyperestraier";
284         my $cgi=basename($config{cgiurl});
285         $cgi=~s/\..*$//;
286         open(TEMPLATE, ">$estdir/$cgi.tmpl") ||
287                 error("write $estdir/$cgi.tmpl: $!");
288         print TEMPLATE misctemplate("search", 
289                 "<!--ESTFORM-->\n\n<!--ESTRESULT-->\n\n<!--ESTINFO-->\n\n");
290         close TEMPLATE;
291         open(TEMPLATE, ">$estdir/$cgi.conf") ||
292                 error("write $estdir/$cgi.conf: $!");
293         my $template=HTML::Template->new(
294                 filename => "$config{templatedir}/estseek.conf"
295         );
296         eval q{use Cwd 'abs_path'};
297         $template->param(
298                 index => $estdir,
299                 tmplfile => "$estdir/$cgi.tmpl",
300                 destdir => abs_path($config{destdir}),
301                 url => $config{url},
302         );
303         print TEMPLATE $template->output;
304         close TEMPLATE;
305         $cgi="$estdir/".basename($config{cgiurl});
306         unlink($cgi);
307         symlink("/usr/lib/estraier/estseek.cgi", $cgi) ||
308                 error("symlink $cgi: $!");
309 } # }}}
310
311 sub estcmd ($;@) { #{{{
312         my @params=split(' ', shift);
313         push @params, "-cl", "$config{wikistatedir}/hyperestraier";
314         if (@_) {
315                 push @params, "-";
316         }
317         
318         my $pid=open(CHILD, "|-");
319         if ($pid) {
320                 # parent
321                 foreach (@_) {
322                         print CHILD "$_\n";
323                 }
324                 close(CHILD) || error("estcmd @params exited nonzero: $?");
325         }
326         else {
327                 # child
328                 open(STDOUT, "/dev/null"); # shut it up (closing won't work)
329                 exec("estcmd", @params) || error("can't run estcmd");
330         }
331 } #}}}
332
333 sub refresh () { #{{{
334         # find existing pages
335         my %exists;
336         my @files;
337         eval q{use File::Find};
338         find({
339                 no_chdir => 1,
340                 wanted => sub {
341                         if (/$config{wiki_file_prune_regexp}/) {
342                                 $File::Find::prune=1;
343                         }
344                         elsif (! -d $_ && ! -l $_) {
345                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
346                                 if (! defined $f) {
347                                         warn("skipping bad filename $_\n");
348                                 }
349                                 else {
350                                         $f=~s/^\Q$config{srcdir}\E\/?//;
351                                         push @files, $f;
352                                         $exists{pagename($f)}=1;
353                                 }
354                         }
355                 },
356         }, $config{srcdir});
357         find({
358                 no_chdir => 1,
359                 wanted => sub {
360                         if (/$config{wiki_file_prune_regexp}/) {
361                                 $File::Find::prune=1;
362                         }
363                         elsif (! -d $_ && ! -l $_) {
364                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
365                                 if (! defined $f) {
366                                         warn("skipping bad filename $_\n");
367                                 }
368                                 else {
369                                         # Don't add files that are in the
370                                         # srcdir.
371                                         $f=~s/^\Q$config{underlaydir}\E\/?//;
372                                         if (! -e "$config{srcdir}/$f" && 
373                                             ! -l "$config{srcdir}/$f") {
374                                                 push @files, $f;
375                                                 $exists{pagename($f)}=1;
376                                         }
377                                 }
378                         }
379                 },
380         }, $config{underlaydir});
381
382         my %rendered;
383
384         # check for added or removed pages
385         my @add;
386         foreach my $file (@files) {
387                 my $page=pagename($file);
388                 if (! $oldpagemtime{$page}) {
389                         debug("new page $page") unless exists $pagectime{$page};
390                         push @add, $file;
391                         $links{$page}=[];
392                         $pagesources{$page}=$file;
393                         $pagectime{$page}=mtime(srcfile($file))
394                                 unless exists $pagectime{$page};
395                 }
396         }
397         my @del;
398         foreach my $page (keys %oldpagemtime) {
399                 if (! $exists{$page}) {
400                         debug("removing old page $page");
401                         push @del, $pagesources{$page};
402                         prune($config{destdir}."/".$renderedfiles{$page});
403                         delete $renderedfiles{$page};
404                         $oldpagemtime{$page}=0;
405                         delete $pagesources{$page};
406                 }
407         }
408         
409         # render any updated files
410         foreach my $file (@files) {
411                 my $page=pagename($file);
412                 
413                 if (! exists $oldpagemtime{$page} ||
414                     mtime(srcfile($file)) > $oldpagemtime{$page}) {
415                         debug("rendering changed file $file");
416                         render($file);
417                         $rendered{$file}=1;
418                 }
419         }
420         
421         # if any files were added or removed, check to see if each page
422         # needs an update due to linking to them or inlining them.
423         # TODO: inefficient; pages may get rendered above and again here;
424         # problem is the bestlink may have changed and we won't know until
425         # now
426         if (@add || @del) {
427 FILE:           foreach my $file (@files) {
428                         my $page=pagename($file);
429                         foreach my $f (@add, @del) {
430                                 my $p=pagename($f);
431                                 foreach my $link (@{$links{$page}}) {
432                                         if (bestlink($page, $link) eq $p) {
433                                                 debug("rendering $file, which links to $p");
434                                                 render($file);
435                                                 $rendered{$file}=1;
436                                                 next FILE;
437                                         }
438                                 }
439                         }
440                 }
441         }
442
443         # Handle backlinks; if a page has added/removed links, update the
444         # pages it links to. Also handles rebuilding dependat pages.
445         # TODO: inefficient; pages may get rendered above and again here;
446         # problem is the backlinks could be wrong in the first pass render
447         # above
448         if (%rendered || @del) {
449                 foreach my $f (@files) {
450                         my $p=pagename($f);
451                         if (exists $depends{$p}) {
452                                 foreach my $file (keys %rendered, @del) {
453                                         next if $f eq $file;
454                                         my $page=pagename($file);
455                                         if (globlist_match($page, $depends{$p})) {
456                                                 debug("rendering $f, which depends on $page");
457                                                 render($f);
458                                                 $rendered{$f}=1;
459                                                 last;
460                                         }
461                                 }
462                         }
463                 }
464                 
465                 my %linkchanged;
466                 foreach my $file (keys %rendered, @del) {
467                         my $page=pagename($file);
468                         
469                         if (exists $links{$page}) {
470                                 foreach my $link (map { bestlink($page, $_) } @{$links{$page}}) {
471                                         if (length $link &&
472                                             (! exists $oldlinks{$page} ||
473                                              ! grep { bestlink($page, $_) eq $link } @{$oldlinks{$page}})) {
474                                                 $linkchanged{$link}=1;
475                                         }
476                                 }
477                         }
478                         if (exists $oldlinks{$page}) {
479                                 foreach my $link (map { bestlink($page, $_) } @{$oldlinks{$page}}) {
480                                         if (length $link &&
481                                             (! exists $links{$page} || 
482                                              ! grep { bestlink($page, $_) eq $link } @{$links{$page}})) {
483                                                 $linkchanged{$link}=1;
484                                         }
485                                 }
486                         }
487                 }
488                 foreach my $link (keys %linkchanged) {
489                         my $linkfile=$pagesources{$link};
490                         if (defined $linkfile) {
491                                 debug("rendering $linkfile, to update its backlinks");
492                                 render($linkfile);
493                                 $rendered{$linkfile}=1;
494                         }
495                 }
496         }
497
498         if ($config{hyperestraier} && (%rendered || @del)) {
499                 debug("updating hyperestraier search index");
500                 if (%rendered) {
501                         estcmd("gather -cm -bc -cl -sd", 
502                                 map { $config{destdir}."/".$renderedfiles{pagename($_)} }
503                                 keys %rendered);
504                 }
505                 if (@del) {
506                         estcmd("purge -cl");
507                 }
508                 
509                 debug("generating hyperestraier cgi config");
510                 estcfg();
511         }
512 } #}}}
513
514 1