rather a lot of changes to make hyperestraier search be a plugin, allowing
[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                 
267                 $links{$page}=[findlinks($content, $page)];
268                 delete $depends{$page};
269                 
270                 $content=linkify($content, $page);
271                 $content=preprocess($page, $content);
272                 $content=htmlize($type, $content);
273                 
274                 check_overwrite("$config{destdir}/".htmlpage($page), $page);
275                 writefile(htmlpage($page), $config{destdir},
276                         genpage($content, $page, mtime($srcfile)));
277                 $oldpagemtime{$page}=time;
278                 $renderedfiles{$page}=htmlpage($page);
279         }
280         else {
281                 my $content=readfile($srcfile, 1);
282                 $links{$file}=[];
283                 delete $depends{$file};
284                 check_overwrite("$config{destdir}/$file", $file);
285                 writefile($file, $config{destdir}, $content, 1);
286                 $oldpagemtime{$file}=time;
287                 $renderedfiles{$file}=$file;
288         }
289 } #}}}
290
291 sub prune ($) { #{{{
292         my $file=shift;
293
294         unlink($file);
295         my $dir=dirname($file);
296         while (rmdir($dir)) {
297                 $dir=dirname($dir);
298         }
299 } #}}}
300
301 sub refresh () { #{{{
302         # find existing pages
303         my %exists;
304         my @files;
305         eval q{use File::Find};
306         find({
307                 no_chdir => 1,
308                 wanted => sub {
309                         if (/$config{wiki_file_prune_regexp}/) {
310                                 $File::Find::prune=1;
311                         }
312                         elsif (! -d $_ && ! -l $_) {
313                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
314                                 if (! defined $f) {
315                                         warn("skipping bad filename $_\n");
316                                 }
317                                 else {
318                                         $f=~s/^\Q$config{srcdir}\E\/?//;
319                                         push @files, $f;
320                                         $exists{pagename($f)}=1;
321                                 }
322                         }
323                 },
324         }, $config{srcdir});
325         find({
326                 no_chdir => 1,
327                 wanted => sub {
328                         if (/$config{wiki_file_prune_regexp}/) {
329                                 $File::Find::prune=1;
330                         }
331                         elsif (! -d $_ && ! -l $_) {
332                                 my ($f)=/$config{wiki_file_regexp}/; # untaint
333                                 if (! defined $f) {
334                                         warn("skipping bad filename $_\n");
335                                 }
336                                 else {
337                                         # Don't add files that are in the
338                                         # srcdir.
339                                         $f=~s/^\Q$config{underlaydir}\E\/?//;
340                                         if (! -e "$config{srcdir}/$f" && 
341                                             ! -l "$config{srcdir}/$f") {
342                                                 push @files, $f;
343                                                 $exists{pagename($f)}=1;
344                                         }
345                                 }
346                         }
347                 },
348         }, $config{underlaydir});
349
350         my %rendered;
351
352         # check for added or removed pages
353         my @add;
354         foreach my $file (@files) {
355                 my $page=pagename($file);
356                 if (! $oldpagemtime{$page}) {
357                         debug("new page $page") unless exists $pagectime{$page};
358                         push @add, $file;
359                         $links{$page}=[];
360                         $pagesources{$page}=$file;
361                         $pagectime{$page}=mtime(srcfile($file))
362                                 unless exists $pagectime{$page};
363                 }
364         }
365         my @del;
366         foreach my $page (keys %oldpagemtime) {
367                 if (! $exists{$page}) {
368                         debug("removing old page $page");
369                         push @del, $pagesources{$page};
370                         prune($config{destdir}."/".$renderedfiles{$page});
371                         delete $renderedfiles{$page};
372                         $oldpagemtime{$page}=0;
373                         delete $pagesources{$page};
374                 }
375         }
376         
377         # render any updated files
378         foreach my $file (@files) {
379                 my $page=pagename($file);
380                 
381                 if (! exists $oldpagemtime{$page} ||
382                     mtime(srcfile($file)) > $oldpagemtime{$page}) {
383                         debug("rendering changed file $file");
384                         render($file);
385                         $rendered{$file}=1;
386                 }
387         }
388         
389         # if any files were added or removed, check to see if each page
390         # needs an update due to linking to them or inlining them.
391         # TODO: inefficient; pages may get rendered above and again here;
392         # problem is the bestlink may have changed and we won't know until
393         # now
394         if (@add || @del) {
395 FILE:           foreach my $file (@files) {
396                         my $page=pagename($file);
397                         foreach my $f (@add, @del) {
398                                 my $p=pagename($f);
399                                 foreach my $link (@{$links{$page}}) {
400                                         if (bestlink($page, $link) eq $p) {
401                                                 debug("rendering $file, which links to $p");
402                                                 render($file);
403                                                 $rendered{$file}=1;
404                                                 next FILE;
405                                         }
406                                 }
407                         }
408                 }
409         }
410
411         # Handle backlinks; if a page has added/removed links, update the
412         # pages it links to. Also handles rebuilding dependat pages.
413         # TODO: inefficient; pages may get rendered above and again here;
414         # problem is the backlinks could be wrong in the first pass render
415         # above
416         if (%rendered || @del) {
417                 foreach my $f (@files) {
418                         my $p=pagename($f);
419                         if (exists $depends{$p}) {
420                                 foreach my $file (keys %rendered, @del) {
421                                         next if $f eq $file;
422                                         my $page=pagename($file);
423                                         if (globlist_match($page, $depends{$p})) {
424                                                 debug("rendering $f, which depends on $page");
425                                                 render($f);
426                                                 $rendered{$f}=1;
427                                                 last;
428                                         }
429                                 }
430                         }
431                 }
432                 
433                 my %linkchanged;
434                 foreach my $file (keys %rendered, @del) {
435                         my $page=pagename($file);
436                         
437                         if (exists $links{$page}) {
438                                 foreach my $link (map { bestlink($page, $_) } @{$links{$page}}) {
439                                         if (length $link &&
440                                             (! exists $oldlinks{$page} ||
441                                              ! grep { bestlink($page, $_) eq $link } @{$oldlinks{$page}})) {
442                                                 $linkchanged{$link}=1;
443                                         }
444                                 }
445                         }
446                         if (exists $oldlinks{$page}) {
447                                 foreach my $link (map { bestlink($page, $_) } @{$oldlinks{$page}}) {
448                                         if (length $link &&
449                                             (! exists $links{$page} || 
450                                              ! grep { bestlink($page, $_) eq $link } @{$links{$page}})) {
451                                                 $linkchanged{$link}=1;
452                                         }
453                                 }
454                         }
455                 }
456                 foreach my $link (keys %linkchanged) {
457                         my $linkfile=$pagesources{$link};
458                         if (defined $linkfile) {
459                                 debug("rendering $linkfile, to update its backlinks");
460                                 render($linkfile);
461                                 $rendered{$linkfile}=1;
462                         }
463                 }
464         }
465
466         if (@del && exists $hooks{delete}) {
467                 foreach my $id (keys %{$hooks{delete}}) {
468                         $hooks{delete}{$id}{call}->(@del);
469                 }
470         }
471         if (%rendered && exists $hooks{render}) {
472                 foreach my $id (keys %{$hooks{render}}) {
473                         $hooks{render}{$id}{call}->(keys %rendered);
474                 }
475         }
476 } #}}}
477
478 1