use a multi word link in example, as this is a bit confusing
[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 $hooks{preprocess}{$command}) {
138                         my %params;
139                         while ($params =~ /(\w+)=\"([^"]+)"(\s+|$)/g) {
140                                 $params{$1}=$2;
141                         }
142                         return $hooks{preprocess}{$command}{call}->(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         $template->param(headercontent => $config{headercontent});
210
211         $template->param(
212                 title => $title,
213                 wikiname => $config{wikiname},
214                 parentlinks => [parentlinks($page)],
215                 content => $content,
216                 backlinks => [backlinks($page)],
217                 discussionlink => htmllink($page, "Discussion", 1, 1),
218                 mtime => scalar(gmtime($mtime)),
219                 styleurl => styleurl($page),
220         );
221         
222         return $template->output;
223 } #}}}
224
225 sub check_overwrite ($$) { #{{{
226         # Important security check. Make sure to call this before saving
227         # any files to the source directory.
228         my $dest=shift;
229         my $src=shift;
230         
231         if (! exists $renderedfiles{$src} && -e $dest && ! $config{rebuild}) {
232                 error("$dest already exists and was rendered from ".
233                         join(" ",(grep { $renderedfiles{$_} eq $dest } keys
234                                 %renderedfiles)).
235                         ", before, so not rendering from $src");
236         }
237 } #}}}
238
239 sub mtime ($) { #{{{
240         my $file=shift;
241         
242         return (stat($file))[9];
243 } #}}}
244
245 sub findlinks ($$) { #{{{
246         my $content=shift;
247         my $page=shift;
248
249         my @links;
250         while ($content =~ /(?<!\\)$config{wiki_link_regexp}/g) {
251                 push @links, titlepage($2);
252         }
253         # Discussion links are a special case since they're not in the text
254         # of the page, but on its template.
255         return @links, "$page/discussion";
256 } #}}}
257
258 sub render ($) { #{{{
259         my $file=shift;
260         
261         my $type=pagetype($file);
262         my $srcfile=srcfile($file);
263         if ($type ne 'unknown') {
264                 my $content=readfile($srcfile);
265                 my $page=pagename($file);
266                 delete $depends{$page};
267                 
268                 if (exists $hooks{filter}) {
269                         foreach my $id (keys %{$hooks{filter}}) {
270                                 $content=$hooks{filter}{$id}{call}->(
271                                         page => $page,
272                                         content => $content
273                                 );
274                         }
275                 }
276                 
277                 $links{$page}=[findlinks($content, $page)];
278                 
279                 $content=linkify($content, $page);
280                 $content=preprocess($page, $content);
281                 $content=htmlize($type, $content);
282                 
283                 check_overwrite("$config{destdir}/".htmlpage($page), $page);
284                 writefile(htmlpage($page), $config{destdir},
285                         genpage($content, $page, mtime($srcfile)));
286                 $oldpagemtime{$page}=time;
287                 $renderedfiles{$page}=htmlpage($page);
288         }
289         else {
290                 my $content=readfile($srcfile, 1);
291                 $links{$file}=[];
292                 delete $depends{$file};
293                 check_overwrite("$config{destdir}/$file", $file);
294                 writefile($file, $config{destdir}, $content, 1);
295                 $oldpagemtime{$file}=time;
296                 $renderedfiles{$file}=$file;
297         }
298 } #}}}
299
300 sub prune ($) { #{{{
301         my $file=shift;
302
303         unlink($file);
304         my $dir=dirname($file);
305         while (rmdir($dir)) {
306                 $dir=dirname($dir);
307         }
308 } #}}}
309
310 sub refresh () { #{{{
311         # find existing pages
312         my %exists;
313         my @files;
314         eval q{use File::Find};
315         find({
316                 no_chdir => 1,
317                 wanted => sub {
318                         if (/$config{wiki_file_prune_regexp}/) {
319                                 $File::Find::prune=1;
320                         }
321                         elsif (! -d $_ && ! -l $_) {
322                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
323                                 if (! defined $f) {
324                                         warn("skipping bad filename $_\n");
325                                 }
326                                 else {
327                                         $f=~s/^\Q$config{srcdir}\E\/?//;
328                                         push @files, $f;
329                                         $exists{pagename($f)}=1;
330                                 }
331                         }
332                 },
333         }, $config{srcdir});
334         find({
335                 no_chdir => 1,
336                 wanted => sub {
337                         if (/$config{wiki_file_prune_regexp}/) {
338                                 $File::Find::prune=1;
339                         }
340                         elsif (! -d $_ && ! -l $_) {
341                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
342                                 if (! defined $f) {
343                                         warn("skipping bad filename $_\n");
344                                 }
345                                 else {
346                                         # Don't add files that are in the
347                                         # srcdir.
348                                         $f=~s/^\Q$config{underlaydir}\E\/?//;
349                                         if (! -e "$config{srcdir}/$f" && 
350                                             ! -l "$config{srcdir}/$f") {
351                                                 push @files, $f;
352                                                 $exists{pagename($f)}=1;
353                                         }
354                                 }
355                         }
356                 },
357         }, $config{underlaydir});
358
359         my %rendered;
360
361         # check for added or removed pages
362         my @add;
363         foreach my $file (@files) {
364                 my $page=pagename($file);
365                 if (! $oldpagemtime{$page}) {
366                         debug("new page $page") unless exists $pagectime{$page};
367                         push @add, $file;
368                         $links{$page}=[];
369                         $pagesources{$page}=$file;
370                         $pagectime{$page}=mtime(srcfile($file))
371                                 unless exists $pagectime{$page};
372                 }
373         }
374         my @del;
375         foreach my $page (keys %oldpagemtime) {
376                 if (! $exists{$page}) {
377                         debug("removing old page $page");
378                         push @del, $pagesources{$page};
379                         prune($config{destdir}."/".$renderedfiles{$page});
380                         delete $renderedfiles{$page};
381                         $oldpagemtime{$page}=0;
382                         delete $pagesources{$page};
383                 }
384         }
385         
386         # render any updated files
387         foreach my $file (@files) {
388                 my $page=pagename($file);
389                 
390                 if (! exists $oldpagemtime{$page} ||
391                     mtime(srcfile($file)) > $oldpagemtime{$page}) {
392                         debug("rendering changed file $file");
393                         render($file);
394                         $rendered{$file}=1;
395                 }
396         }
397         
398         # if any files were added or removed, check to see if each page
399         # needs an update due to linking to them or inlining them.
400         # TODO: inefficient; pages may get rendered above and again here;
401         # problem is the bestlink may have changed and we won't know until
402         # now
403         if (@add || @del) {
404 FILE:           foreach my $file (@files) {
405                         my $page=pagename($file);
406                         foreach my $f (@add, @del) {
407                                 my $p=pagename($f);
408                                 foreach my $link (@{$links{$page}}) {
409                                         if (bestlink($page, $link) eq $p) {
410                                                 debug("rendering $file, which links to $p");
411                                                 render($file);
412                                                 $rendered{$file}=1;
413                                                 next FILE;
414                                         }
415                                 }
416                         }
417                 }
418         }
419
420         # Handle backlinks; if a page has added/removed links, update the
421         # pages it links to. Also handles rebuilding dependat pages.
422         # TODO: inefficient; pages may get rendered above and again here;
423         # problem is the backlinks could be wrong in the first pass render
424         # above
425         if (%rendered || @del) {
426                 foreach my $f (@files) {
427                         my $p=pagename($f);
428                         if (exists $depends{$p}) {
429                                 foreach my $file (keys %rendered, @del) {
430                                         next if $f eq $file;
431                                         my $page=pagename($file);
432                                         if (globlist_match($page, $depends{$p})) {
433                                                 debug("rendering $f, which depends on $page");
434                                                 render($f);
435                                                 $rendered{$f}=1;
436                                                 last;
437                                         }
438                                 }
439                         }
440                 }
441                 
442                 my %linkchanged;
443                 foreach my $file (keys %rendered, @del) {
444                         my $page=pagename($file);
445                         
446                         if (exists $links{$page}) {
447                                 foreach my $link (map { bestlink($page, $_) } @{$links{$page}}) {
448                                         if (length $link &&
449                                             (! exists $oldlinks{$page} ||
450                                              ! grep { bestlink($page, $_) eq $link } @{$oldlinks{$page}})) {
451                                                 $linkchanged{$link}=1;
452                                         }
453                                 }
454                         }
455                         if (exists $oldlinks{$page}) {
456                                 foreach my $link (map { bestlink($page, $_) } @{$oldlinks{$page}}) {
457                                         if (length $link &&
458                                             (! exists $links{$page} || 
459                                              ! grep { bestlink($page, $_) eq $link } @{$links{$page}})) {
460                                                 $linkchanged{$link}=1;
461                                         }
462                                 }
463                         }
464                 }
465                 foreach my $link (keys %linkchanged) {
466                         my $linkfile=$pagesources{$link};
467                         if (defined $linkfile) {
468                                 debug("rendering $linkfile, to update its backlinks");
469                                 render($linkfile);
470                                 $rendered{$linkfile}=1;
471                         }
472                 }
473         }
474
475         if (@del && exists $hooks{delete}) {
476                 foreach my $id (keys %{$hooks{delete}}) {
477                         $hooks{delete}{$id}{call}->(@del);
478                 }
479         }
480         if (%rendered && exists $hooks{render}) {
481                 foreach my $id (keys %{$hooks{render}}) {
482                         $hooks{render}{$id}{call}->(keys %rendered);
483                 }
484         }
485 } #}}}
486
487 1