aboutsummaryrefslogtreecommitdiff
path: root/t
diff options
context:
space:
mode:
Diffstat (limited to 't')
-rwxr-xr-xt/404.t44
-rwxr-xr-xt/add_depends.t70
-rwxr-xr-xt/autoindex.t134
-rwxr-xr-xt/basename.t12
-rwxr-xr-xt/basewiki_brokenlinks.t29
-rw-r--r--t/basewiki_brokenlinks/index.mdwn1
-rwxr-xr-xt/bazaar.t116
-rwxr-xr-xt/beautify_urlpath.t17
-rwxr-xr-xt/bestlink.t33
-rwxr-xr-xt/calculate_changed_links.t58
-rwxr-xr-xt/cmp_path.t48
-rwxr-xr-xt/comments.t57
-rwxr-xr-xt/conflicts.t131
-rwxr-xr-xt/crazy-badass-perl-bug.t20
-rwxr-xr-xt/cvs.t708
-rwxr-xr-xt/dirname.t12
-rwxr-xr-xt/file_pruned.t40
-rwxr-xr-xt/find_src_files.t97
-rwxr-xr-xt/git.t97
-rwxr-xr-xt/html.t30
-rwxr-xr-xt/htmlbalance.t23
-rwxr-xr-xt/htmlize.t85
-rwxr-xr-xt/index.t161
-rwxr-xr-xt/linkify.t112
-rwxr-xr-xt/linkpage.t13
-rwxr-xr-xt/map.t242
-rwxr-xr-xt/mercurial.t75
-rwxr-xr-xt/openiduser.t42
-rwxr-xr-xt/pagename.t35
-rwxr-xr-xt/pagespec_match.t147
-rwxr-xr-xt/pagespec_match_list.t174
-rwxr-xr-xt/pagespec_match_result.t84
-rwxr-xr-xt/pagetitle.t13
-rwxr-xr-xt/parentlinks.t81
-rw-r--r--t/parentlinks/templates/parentlinks.tmpl4
-rwxr-xr-xt/permalink.t14
-rwxr-xr-xt/po.t250
-rwxr-xr-xt/preprocess.t83
-rwxr-xr-xt/prune.t23
-rwxr-xr-xt/readfile.t12
-rwxr-xr-xt/renamepage.t51
-rwxr-xr-xt/rssurls.t37
-rwxr-xr-xt/rst.t22
-rwxr-xr-xt/svn.t78
-rwxr-xr-xt/syntax.t20
-rwxr-xr-xt/tag.t88
-rwxr-xr-xt/template_syntax.t15
-rwxr-xr-xt/templates_documented.t14
-rw-r--r--t/test1.mdwn2
-rw-r--r--t/test2.mdwn5
-rw-r--r--t/test3.mdwn1
-rw-r--r--t/tinyblog/index.mdwn1
-rw-r--r--t/tinyblog/post.mdwn1
-rwxr-xr-xt/titlepage.t13
-rwxr-xr-xt/trail.t292
-rwxr-xr-xt/urlto.t51
-rwxr-xr-xt/yesno.t23
57 files changed, 4141 insertions, 0 deletions
diff --git a/t/404.t b/t/404.t
new file mode 100755
index 000000000..0bb3c6063
--- /dev/null
+++ b/t/404.t
@@ -0,0 +1,44 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 17;
+
+BEGIN { use_ok("IkiWiki::Plugin::404"); }
+
+sub cgi_page_from_404 {
+ return IkiWiki::Plugin::404::cgi_page_from_404(shift, shift, shift);
+}
+
+$IkiWiki::config{htmlext} = 'html';
+
+is(cgi_page_from_404('/', 'http://example.com', 1), 'index');
+is(cgi_page_from_404('/index.html', 'http://example.com', 0), 'index');
+is(cgi_page_from_404('/', 'http://example.com/', 1), 'index');
+is(cgi_page_from_404('/index.html', 'http://example.com/', 0), 'index');
+
+is(cgi_page_from_404('/~user/foo/bar', 'http://example.com/~user', 1),
+ 'foo/bar');
+is(cgi_page_from_404('/~user/foo/bar/index.html', 'http://example.com/~user', 1),
+ 'foo/bar');
+is(cgi_page_from_404('/~user/foo/bar/', 'http://example.com/~user', 1),
+ 'foo/bar');
+is(cgi_page_from_404('/~user/foo/bar.html', 'http://example.com/~user', 0),
+ 'foo/bar');
+
+is(cgi_page_from_404('/~user/foo/bar', 'http://example.com/~user/', 1),
+ 'foo/bar');
+is(cgi_page_from_404('/~user/foo/bar/index.html', 'http://example.com/~user/', 1),
+ 'foo/bar');
+is(cgi_page_from_404('/~user/foo/bar/', 'http://example.com/~user/', 1),
+ 'foo/bar');
+is(cgi_page_from_404('/~user/foo/bar.html', 'http://example.com/~user/', 0),
+ 'foo/bar');
+
+is(cgi_page_from_404('/~user/foo', 'https://example.com/~user', 1),
+ 'foo');
+is(cgi_page_from_404('/~user/foo/index.html', 'https://example.com/~user', 1),
+ 'foo');
+is(cgi_page_from_404('/~user/foo/', 'https://example.com/~user', 1),
+ 'foo');
+is(cgi_page_from_404('/~user/foo.html', 'https://example.com/~user', 0),
+ 'foo');
diff --git a/t/add_depends.t b/t/add_depends.t
new file mode 100755
index 000000000..aa58fb0ff
--- /dev/null
+++ b/t/add_depends.t
@@ -0,0 +1,70 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 40;
+
+BEGIN { use_ok("IkiWiki"); }
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+IkiWiki::checkconfig();
+
+$pagesources{"foo$_"}="foo$_.mdwn" for 0..9;
+
+# avoids adding an unparseable pagespec
+ok(! add_depends("foo0", "foo and (bar"));
+ok(! add_depends("foo0", "foo another"));
+
+# simple and not-so-simple dependencies split
+ok(add_depends("foo0", "*"));
+ok(add_depends("foo0", "bar"));
+ok(add_depends("foo0", "BAZ"));
+ok(exists $IkiWiki::depends_simple{foo0}{"bar"});
+ok(exists $IkiWiki::depends_simple{foo0}{"baz"}); # lowercase
+ok(! exists $IkiWiki::depends_simple{foo0}{"*"});
+ok(! exists $IkiWiki::depends{foo0}{"bar"});
+ok(! exists $IkiWiki::depends{foo0}{"baz"});
+
+# default dependencies are content dependencies
+ok($IkiWiki::depends{foo0}{"*"} & $IkiWiki::DEPEND_CONTENT);
+ok(! ($IkiWiki::depends{foo0}{"*"} & ($IkiWiki::DEPEND_PRESENCE | $IkiWiki::DEPEND_LINKS)));
+ok($IkiWiki::depends_simple{foo0}{"bar"} & $IkiWiki::DEPEND_CONTENT);
+ok(! ($IkiWiki::depends_simple{foo0}{"bar"} & ($IkiWiki::DEPEND_PRESENCE | $IkiWiki::DEPEND_LINKS)));
+
+# adding other dep types standalone
+ok(add_depends("foo2", "*", deptype("presence")));
+ok(add_depends("foo2", "bar", deptype("links")));
+ok($IkiWiki::depends{foo2}{"*"} & $IkiWiki::DEPEND_PRESENCE);
+ok(! ($IkiWiki::depends{foo2}{"*"} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_LINKS)));
+ok($IkiWiki::depends_simple{foo2}{"bar"} & $IkiWiki::DEPEND_LINKS);
+ok(! ($IkiWiki::depends_simple{foo2}{"bar"} & ($IkiWiki::DEPEND_PRESENCE | $IkiWiki::DEPEND_CONTENT)));
+
+# adding combined dep types
+ok(add_depends("foo2", "baz", deptype("links", "presence")));
+ok($IkiWiki::depends_simple{foo2}{"baz"} & $IkiWiki::DEPEND_LINKS);
+ok($IkiWiki::depends_simple{foo2}{"baz"} & $IkiWiki::DEPEND_PRESENCE);
+ok(! ($IkiWiki::depends_simple{foo2}{"baz"} & $IkiWiki::DEPEND_CONTENT));
+
+# adding dep types to existing dependencies should merge the flags
+ok(add_depends("foo2", "baz"));
+ok($IkiWiki::depends_simple{foo2}{"baz"} & $IkiWiki::DEPEND_LINKS);
+ok($IkiWiki::depends_simple{foo2}{"baz"} & $IkiWiki::DEPEND_PRESENCE);
+ok(($IkiWiki::depends_simple{foo2}{"baz"} & $IkiWiki::DEPEND_CONTENT));
+ok(add_depends("foo2", "bar", deptype("presence"))); # had only links before
+ok($IkiWiki::depends_simple{foo2}{"bar"} & ($IkiWiki::DEPEND_LINKS | $IkiWiki::DEPEND_PRESENCE));
+ok(! ($IkiWiki::depends_simple{foo2}{"bar"} & $IkiWiki::DEPEND_CONTENT));
+ok(add_depends("foo0", "bar", deptype("links"))); # had only content before
+ok($IkiWiki::depends{foo0}{"*"} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_LINKS));
+ok(! ($IkiWiki::depends{foo0}{"*"} & $IkiWiki::DEPEND_PRESENCE));
+
+# content is the default if unknown types are entered
+ok(add_depends("foo9", "*", deptype("monkey")));
+ok($IkiWiki::depends{foo9}{"*"} & $IkiWiki::DEPEND_CONTENT);
+ok(! ($IkiWiki::depends{foo9}{"*"} & ($IkiWiki::DEPEND_PRESENCE | $IkiWiki::DEPEND_LINKS)));
+
+# Influences are added for dependencies involving links.
+$pagesources{"foo"}="foo.mdwn";
+$links{foo}=[qw{bar}];
+$pagesources{"bar"}="bar.mdwn";
+$links{bar}=[qw{}];
+ok(add_depends("foo", "link(bar) and backlink(meep)"));
+ok($IkiWiki::depends_simple{foo}{foo} == $IkiWiki::DEPEND_LINKS);
diff --git a/t/autoindex.t b/t/autoindex.t
new file mode 100755
index 000000000..d16e44ca8
--- /dev/null
+++ b/t/autoindex.t
@@ -0,0 +1,134 @@
+#!/usr/bin/perl
+package IkiWiki;
+
+use warnings;
+use strict;
+use Test::More tests => 38;
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Render"); }
+BEGIN { use_ok("IkiWiki::Plugin::aggregate"); }
+BEGIN { use_ok("IkiWiki::Plugin::autoindex"); }
+BEGIN { use_ok("IkiWiki::Plugin::html"); }
+BEGIN { use_ok("IkiWiki::Plugin::mdwn"); }
+
+ok(! system("rm -rf t/tmp; mkdir t/tmp"));
+
+$config{verbose} = 1;
+$config{srcdir} = 't/tmp';
+$config{underlaydir} = 't/tmp';
+$config{underlaydirbase} = '.';
+$config{templatedir} = 'templates';
+$config{usedirs} = 1;
+$config{htmlext} = 'html';
+$config{wiki_file_chars} = "-[:alnum:]+/.:_";
+$config{userdir} = "users";
+$config{tagbase} = "tags";
+$config{default_pageext} = "mdwn";
+$config{wiki_file_prune_regexps} = [qr/^\./];
+$config{autoindex_commit} = 0;
+
+is(checkconfig(), 1);
+
+%oldrenderedfiles=%pagectime=();
+%pagesources=%pagemtime=%oldlinks=%links=%depends=%typedlinks=%oldtypedlinks=
+%destsources=%renderedfiles=%pagecase=%pagestate=();
+
+# Pages that (we claim) were deleted in an earlier pass. We're using deleted,
+# not autofile, to test backwards compat.
+$wikistate{autoindex}{deleted}{deleted} = 1;
+$wikistate{autoindex}{deleted}{expunged} = 1;
+$wikistate{autoindex}{deleted}{reinstated} = 1;
+
+foreach my $page (qw(tags/numbers deleted/bar reinstated reinstated/foo gone/bar)) {
+ # we use a non-default extension for these, so they're distinguishable
+ # from programmatically-created pages
+ $pagesources{$page} = "$page.html";
+ $pagemtime{$page} = $pagectime{$page} = 1000000;
+ writefile("$page.html", "t/tmp", "your ad here");
+}
+
+# a directory containing only an internal page shouldn't be indexed
+$pagesources{"has_internal/internal"} = "has_internal/internal._aggregated";
+$pagemtime{"has_internal/internal"} = 123456789;
+$pagectime{"has_internal/internal"} = 123456789;
+writefile("has_internal/internal._aggregated", "t/tmp", "this page is internal");
+
+# a directory containing only an attachment should be indexed
+$pagesources{"attached/pie.jpg"} = "attached/pie.jpg";
+$pagemtime{"attached/pie.jpg"} = 123456789;
+$pagectime{"attached/pie.jpg"} = 123456789;
+writefile("attached/pie.jpg", "t/tmp", "I lied, this isn't a real JPEG");
+
+# "gone" disappeared just before this refresh pass so it still has a mtime
+$pagemtime{gone} = $pagectime{gone} = 1000000;
+
+my %pages;
+my @del;
+
+IkiWiki::Plugin::autoindex::refresh();
+
+# this page is still on record as having been deleted, because it has
+# a reason to be re-created
+is($wikistate{autoindex}{autofile}{"deleted.mdwn"}, 1);
+is($autofiles{"deleted.mdwn"}{plugin}, "autoindex");
+%pages = ();
+@del = ();
+IkiWiki::gen_autofile("deleted.mdwn", \%pages, \@del);
+is_deeply(\%pages, {});
+is_deeply(\@del, []);
+ok(! -f "t/tmp/deleted.mdwn");
+
+# this page is tried as an autofile, but because it'll be in @del, it's not
+# actually created
+ok(! exists $wikistate{autoindex}{autofile}{"gone.mdwn"});
+%pages = ();
+@del = ("gone.mdwn");
+is($autofiles{"gone.mdwn"}{plugin}, "autoindex");
+IkiWiki::gen_autofile("gone.mdwn", \%pages, \@del);
+is_deeply(\%pages, {});
+is_deeply(\@del, ["gone.mdwn"]);
+ok(! -f "t/tmp/gone.mdwn");
+
+# this page does not exist and has no reason to be re-created, but we no longer
+# have a special case for that - see
+# [[todo/autoindex_should_use_add__95__autofile]] - so it won't be created
+# even if it gains subpages later
+is($wikistate{autoindex}{autofile}{"expunged.mdwn"}, 1);
+ok(! exists $autofiles{"expunged.mdwn"});
+ok(! -f "t/tmp/expunged.mdwn");
+
+# a directory containing only an internal page shouldn't be indexed
+ok(! exists $wikistate{autoindex}{autofile}{"has_internal.mdwn"});
+ok(! exists $autofiles{"has_internal.mdwn"});
+ok(! -f "t/tmp/has_internal.mdwn");
+
+# this page was re-created, but that no longer gets a special case
+# (see [[todo/autoindex_should_use_add__95__autofile]]) so it's the same as
+# deleted
+is($wikistate{autoindex}{autofile}{"reinstated.mdwn"}, 1);
+ok(! exists $autofiles{"reinstated.mdwn"});
+ok(! -f "t/tmp/reinstated.mdwn");
+
+# needs creating (deferred; part of the autofile mechanism now)
+ok(! exists $wikistate{autoindex}{autofile}{"tags.mdwn"});
+%pages = ();
+@del = ();
+is($autofiles{"tags.mdwn"}{plugin}, "autoindex");
+IkiWiki::gen_autofile("tags.mdwn", \%pages, \@del);
+is_deeply(\%pages, {"t/tmp/tags" => 1});
+is_deeply(\@del, []);
+ok(! -s "t/tmp/tags.mdwn");
+ok(-s "t/tmp/.ikiwiki/transient/tags.mdwn");
+
+# needs creating because of an attachment
+ok(! exists $wikistate{autoindex}{autofile}{"attached.mdwn"});
+%pages = ();
+@del = ();
+is($autofiles{"attached.mdwn"}{plugin}, "autoindex");
+IkiWiki::gen_autofile("attached.mdwn", \%pages, \@del);
+is_deeply(\%pages, {"t/tmp/attached" => 1});
+is_deeply(\@del, []);
+ok(-s "t/tmp/.ikiwiki/transient/attached.mdwn");
+
+1;
diff --git a/t/basename.t b/t/basename.t
new file mode 100755
index 000000000..87ae42cf3
--- /dev/null
+++ b/t/basename.t
@@ -0,0 +1,12 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 6;
+
+BEGIN { use_ok("IkiWiki"); }
+
+is(IkiWiki::basename("/home/joey/foo/bar"), "bar");
+is(IkiWiki::basename("./foo"), "foo");
+is(IkiWiki::basename("baz"), "baz");
+is(IkiWiki::basename("/tmp/"), "");
+is(IkiWiki::basename("/home/joey/foo/"), "");
diff --git a/t/basewiki_brokenlinks.t b/t/basewiki_brokenlinks.t
new file mode 100755
index 000000000..74ddc61c5
--- /dev/null
+++ b/t/basewiki_brokenlinks.t
@@ -0,0 +1,29 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More 'no_plan';
+
+ok(! system("rm -rf t/tmp; mkdir t/tmp"));
+ok(! system("make -s ikiwiki.out"));
+ok(! system("make underlay_install DESTDIR=`pwd`/t/tmp/install PREFIX=/usr >/dev/null"));
+
+foreach my $plugin ("", "listdirectives") {
+ ok(! system("LC_ALL=C perl -I. ./ikiwiki.out -rebuild -plugin brokenlinks ".
+ # always enabled because pages link to it conditionally,
+ # which brokenlinks cannot handle properly
+ "-plugin smiley ".
+ ($plugin ? "-plugin $plugin " : "").
+ "-underlaydir=t/tmp/install/usr/share/ikiwiki/basewiki ".
+ "-set underlaydirbase=t/tmp/install/usr/share/ikiwiki ".
+ "-templatedir=templates t/basewiki_brokenlinks t/tmp/out"));
+ my $result=`grep 'no broken links' t/tmp/out/index.html`;
+ ok(length($result));
+ if (! length $result) {
+ print STDERR "\n\nbroken links found".($plugin ? " (with $plugin)" : "")."\n";
+ system("grep '<li>' t/tmp/out/index.html >&2");
+ print STDERR "\n\n";
+ }
+ ok(-e "t/tmp/out/style.css"); # linked to..
+ ok(! system("rm -rf t/tmp/out t/basewiki_brokenlinks/.ikiwiki"));
+}
+ok(! system("rm -rf t/tmp"));
diff --git a/t/basewiki_brokenlinks/index.mdwn b/t/basewiki_brokenlinks/index.mdwn
new file mode 100644
index 000000000..41768f782
--- /dev/null
+++ b/t/basewiki_brokenlinks/index.mdwn
@@ -0,0 +1 @@
+[[!brokenlinks ]]
diff --git a/t/bazaar.t b/t/bazaar.t
new file mode 100755
index 000000000..6e58f48f1
--- /dev/null
+++ b/t/bazaar.t
@@ -0,0 +1,116 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+my $dir;
+BEGIN {
+ $dir = "/tmp/ikiwiki-test-bzr.$$";
+ my $bzr=`which bzr`;
+ chomp $bzr;
+ if (! -x $bzr) {
+ eval q{
+ use Test::More skip_all => "bzr not available"
+ }
+ }
+ if (! mkdir($dir)) {
+ die $@;
+ }
+}
+use Test::More tests => 17;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{rcs} = "bzr";
+$config{srcdir} = "$dir/repo";
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+# XXX
+# This is a workaround for bzr's new requirement that bzr whoami be run
+# before committing. This makes the test suite work with an unconfigured
+# bzr, but ignores the need to have a properly configured bzr before
+# using ikiwiki with bzr.
+$ENV{HOME}=$dir;
+system 'bzr whoami test@example.com';
+
+system "bzr init $config{srcdir}";
+
+use CGI::Session;
+my $session=CGI::Session->new;
+$session->param("name", "Joe User");
+
+# Web commit
+my $test1 = readfile("t/test1.mdwn");
+writefile('test1.mdwn', $config{srcdir}, $test1);
+IkiWiki::rcs_add("test1.mdwn");
+IkiWiki::rcs_commit(
+ file => "test1.mdwn",
+ message => "Added the first page",
+ token => "moo",
+ session => $session);
+
+my @changes;
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 0);
+is($changes[0]{message}[0]{"line"}, "Added the first page");
+is($changes[0]{pages}[0]{"page"}, "test1");
+is($changes[0]{user}, "Joe User");
+
+# Manual commit
+my $username = "Foo Bar";
+my $user = "$username <foo.bar\@example.com>";
+my $message = "Added the second page";
+
+my $test2 = readfile("t/test2.mdwn");
+writefile('test2.mdwn', $config{srcdir}, $test2);
+system "bzr add --quiet $config{srcdir}/test2.mdwn";
+system "bzr commit --quiet --author \"$user\" -m \"$message\" $config{srcdir}";
+
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 1);
+is($changes[0]{message}[0]{"line"}, $message);
+is($changes[0]{user}, $username);
+is($changes[0]{pages}[0]{"page"}, "test2");
+
+is($changes[1]{pages}[0]{"page"}, "test1");
+
+my $ctime = IkiWiki::rcs_getctime("test2.mdwn");
+ok($ctime >= time() - 20);
+
+my $mtime = IkiWiki::rcs_getmtime("test2.mdwn");
+ok($mtime >= time() - 20);
+
+writefile('test3.mdwn', $config{srcdir}, $test1);
+IkiWiki::rcs_add("test3.mdwn");
+IkiWiki::rcs_rename("test3.mdwn", "test4.mdwn");
+IkiWiki::rcs_commit_staged(
+ message => "Added the 4th page",
+ session => $session,
+);
+
+@changes = IkiWiki::rcs_recentchanges(4);
+
+is($#changes, 2);
+is($changes[0]{pages}[0]{"page"}, "test4");
+
+ok(mkdir($config{srcdir}."/newdir"));
+IkiWiki::rcs_rename("test4.mdwn", "newdir/test5.mdwn");
+IkiWiki::rcs_commit_staged(
+ message => "Added the 5th page",
+ session => $session,
+);
+
+@changes = IkiWiki::rcs_recentchanges(4);
+
+is($#changes, 3);
+is($changes[0]{pages}[0]{"page"}, "newdir/test5");
+
+IkiWiki::rcs_remove("newdir/test5.mdwn");
+IkiWiki::rcs_commit_staged(
+ message => "Remove the 5th page",
+ session => $session,
+);
+
+system "rm -rf $dir";
diff --git a/t/beautify_urlpath.t b/t/beautify_urlpath.t
new file mode 100755
index 000000000..94b923d3b
--- /dev/null
+++ b/t/beautify_urlpath.t
@@ -0,0 +1,17 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 8;
+
+BEGIN { use_ok("IkiWiki"); }
+
+$IkiWiki::config{usedirs} = 1;
+$IkiWiki::config{htmlext} = "HTML";
+is(IkiWiki::beautify_urlpath("foo/bar"), "./foo/bar");
+is(IkiWiki::beautify_urlpath("../badger"), "../badger");
+is(IkiWiki::beautify_urlpath("./bleh"), "./bleh");
+is(IkiWiki::beautify_urlpath("foo/index.HTML"), "./foo/");
+is(IkiWiki::beautify_urlpath("index.HTML"), "./");
+is(IkiWiki::beautify_urlpath("../index.HTML"), "../");
+$IkiWiki::config{usedirs} = 0;
+is(IkiWiki::beautify_urlpath("foo/index.HTML"), "./foo/index.HTML");
diff --git a/t/bestlink.t b/t/bestlink.t
new file mode 100755
index 000000000..0020a05e2
--- /dev/null
+++ b/t/bestlink.t
@@ -0,0 +1,33 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 11;
+
+BEGIN { use_ok("IkiWiki"); }
+
+sub test ($$$) {
+ my $page=shift;
+ my $link=shift;
+ my @existing_pages=@{shift()};
+
+ %IkiWiki::pagecase=();
+ %pagesources=();
+ $IkiWiki::config{userdir}="foouserdir";
+ foreach my $page (@existing_pages) {
+ $IkiWiki::pagecase{lc $page}=$page;
+ $pagesources{$page}="$page.mdwn";
+ }
+
+ return bestlink($page, $link);
+}
+
+is(test("bar", "foo", ["bar"]), "", "broken link");
+is(test("bar", "foo", ["bar", "foo"]), "foo", "simple link");
+is(test("bar", "FoO", ["bar", "foo"]), "foo", "simple link with different input case");
+is(test("bar", "foo", ["bar", "fOo"]), "fOo", "simple link with different page case");
+is(test("bar", "FoO", ["bar", "fOo"]), "fOo", "simple link with different page and input case");
+is(test("bar", "Foo", ["bar", "fOo", "foo", "fOO", "Foo", "fOo"]), "Foo", "in case of ambiguity, like case wins");
+is(test("bar", "foo", ["bar", "foo", "bar/foo"]), "bar/foo", "simple subpage link");
+is(test("bar", "foo/subpage", ["bar", "foo", "bar/subpage", "foo/subpage"]), "foo/subpage", "cross subpage link");
+is(test("bar", "bob", ["bar", "foo", "foouserdir/bob"]), "foouserdir/bob", "user link");
+is(test("bar", "bob", ["bar", "foo", "bob", "foouserdir/bob"]), "bob", "non-user link");
diff --git a/t/calculate_changed_links.t b/t/calculate_changed_links.t
new file mode 100755
index 000000000..bf6e2af45
--- /dev/null
+++ b/t/calculate_changed_links.t
@@ -0,0 +1,58 @@
+#!/usr/bin/perl
+package IkiWiki;
+
+use warnings;
+use strict;
+use Test::More tests => 5;
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Render"); }
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+
+%oldrenderedfiles=%pagectime=();
+%pagesources=%pagemtime=%oldlinks=%links=%depends=%typedlinks=%oldtypedlinks=
+%destsources=%renderedfiles=%pagecase=%pagestate=();
+
+IkiWiki::checkconfig();
+
+foreach my $page (qw(tags/a tags/b Reorder Add Remove TypeAdd TypeRemove)) {
+ $pagesources{$page} = "$page.mdwn";
+ $pagemtime{$page} = $pagectime{$page} = 1000000;
+}
+
+$oldlinks{Reorder} = [qw{tags/a tags/b}];
+$links{Reorder} = [qw{tags/b tags/a}];
+
+$oldlinks{Add} = [qw{tags/b}];
+$links{Add} = [qw{tags/a tags/b}];
+
+$oldlinks{Remove} = [qw{tags/a}];
+$links{Remove} = [];
+
+$oldlinks{TypeAdd} = [qw{tags/a tags/b}];
+$links{TypeAdd} = [qw{tags/a tags/b}];
+# This causes TypeAdd to be rebuilt, but isn't a backlink change, so it doesn't
+# cause tags/b to be rebuilt.
+$oldtypedlinks{TypeAdd}{tag} = { "tags/a" => 1 };
+$typedlinks{TypeAdd}{tag} = { "tags/a" => 1, "tags/b" => 1 };
+
+$oldlinks{TypeRemove} = [qw{tags/a tags/b}];
+$links{TypeRemove} = [qw{tags/a tags/b}];
+# This causes TypeRemove to be rebuilt, but isn't a backlink change, so it
+# doesn't cause tags/b to be rebuilt.
+$oldtypedlinks{TypeRemove}{tag} = { "tags/a" => 1 };
+$typedlinks{TypeRemove}{tag} = { "tags/a" => 1, "tags/b" => 1 };
+
+my $oldlink_targets = calculate_old_links([keys %pagesources], []);
+is_deeply($oldlink_targets, {
+ Reorder => { "tags/a" => "tags/a", "tags/b" => "tags/b" },
+ Add => { "tags/b" => "tags/b" },
+ Remove => { "tags/a" => "tags/a" },
+ TypeAdd => { "tags/a" => "tags/a", "tags/b" => "tags/b" },
+ TypeRemove => { "tags/a" => "tags/a", "tags/b" => "tags/b" },
+ });
+my ($backlinkchanged, $linkchangers) = calculate_changed_links([keys %pagesources], [], $oldlink_targets);
+
+is_deeply($backlinkchanged, { "tags/a" => 1 });
+is_deeply($linkchangers, { add => 1, remove => 1, typeadd => 1, typeremove => 1 });
diff --git a/t/cmp_path.t b/t/cmp_path.t
new file mode 100755
index 000000000..9de79f49b
--- /dev/null
+++ b/t/cmp_path.t
@@ -0,0 +1,48 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 25;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+IkiWiki::checkconfig();
+
+sub test {
+ my ($before, $after) = @_;
+
+ $IkiWiki::SortSpec::a = $before;
+ $IkiWiki::SortSpec::b = $after;
+ my $r = IkiWiki::SortSpec::cmp_path();
+
+ if ($before eq $after) {
+ is($r, 0);
+ }
+ else {
+ is($r, -1);
+ }
+
+ $IkiWiki::SortSpec::a = $after;
+ $IkiWiki::SortSpec::b = $before;
+ $r = IkiWiki::SortSpec::cmp_path();
+
+ if ($before eq $after) {
+ is($r, 0);
+ }
+ else {
+ is($r, 1);
+ }
+
+ is_deeply([IkiWiki::SortSpec::sort_pages(\&IkiWiki::SortSpec::cmp_path, $before, $after)],
+ [$before, $after]);
+ is_deeply([IkiWiki::SortSpec::sort_pages(\&IkiWiki::SortSpec::cmp_path, $after, $before)],
+ [$before, $after]);
+}
+
+test("a/b/c", "a/b/c");
+test("a/b", "a/c");
+test("a/z", "z/a");
+test("a", "a/b");
+test("a", "a/b");
+test("a/z", "ab");
diff --git a/t/comments.t b/t/comments.t
new file mode 100755
index 000000000..da2148b6b
--- /dev/null
+++ b/t/comments.t
@@ -0,0 +1,57 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More 'no_plan';
+use IkiWiki;
+
+ok(! system("rm -rf t/tmp"));
+ok(mkdir "t/tmp");
+ok(! system("cp -R t/tinyblog t/tmp/in"));
+ok(mkdir "t/tmp/in/post" or -d "t/tmp/in/post");
+
+my $comment;
+
+$comment = <<EOF;
+[[!comment username="neil"
+ date="1969-07-20T20:17:40Z"
+ content="I landed"]]
+EOF
+#ok(eval { writefile("post/comment_3._comment", "t/tmp/in", $comment); 1 });
+writefile("post/comment_3._comment", "t/tmp/in", $comment);
+
+$comment = <<EOF;
+[[!comment username="christopher"
+ date="1969-02-12T07:00:00Z"
+ content="I explored"]]
+EOF
+writefile("post/comment_2._comment", "t/tmp/in", $comment);
+
+$comment = <<EOF;
+[[!comment username="william"
+ date="1969-01-14T12:00:00Z"
+ content="I conquered"]]
+EOF
+writefile("post/comment_1._comment", "t/tmp/in", $comment);
+
+# Give the files mtimes in the wrong order
+ok(utime(111111111, 111111111, "t/tmp/in/post/comment_3._comment"));
+ok(utime(222222222, 222222222, "t/tmp/in/post/comment_2._comment"));
+ok(utime(333333333, 333333333, "t/tmp/in/post/comment_1._comment"));
+
+# Build the wiki
+ok(! system("make -s ikiwiki.out"));
+ok(! system("perl -I. ./ikiwiki.out -verbose -plugin comments -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -set comments_pagespec='*' -templatedir=templates t/tmp/in t/tmp/out"));
+
+# Check that the comments are in the right order
+
+sub slurp {
+ open my $fh, "<", shift or return undef;
+ local $/;
+ my $content = <$fh>;
+ close $fh or return undef;
+ return $content;
+}
+
+my $content = slurp("t/tmp/out/post/index.html");
+ok(defined $content);
+ok($content =~ m/I conquered.*I explored.*I landed/s);
diff --git a/t/conflicts.t b/t/conflicts.t
new file mode 100755
index 000000000..d7e04d3ae
--- /dev/null
+++ b/t/conflicts.t
@@ -0,0 +1,131 @@
+#!/usr/bin/perl
+# Tests for bugs relating to conflicting files in the srcdir
+use warnings;
+use strict;
+use Test::More tests => 106;
+
+# setup
+my $srcdir="t/tmp/src";
+my $destdir="t/tmp/dest";
+ok(! system("make -s ikiwiki.out"));
+
+# runs ikiwiki to build test site
+sub runiki {
+ my $testdesc=shift;
+ ok((! system("perl -I. ./ikiwiki.out -plugin txt -plugin rawhtml -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates $srcdir $destdir @_")),
+ $testdesc);
+}
+sub refreshiki {
+ runiki(shift);
+}
+sub setupiki {
+ ok(! system("rm -rf $srcdir/.ikiwiki $destdir"));
+ runiki(shift, "--rebuild");
+}
+sub newsrcdir {
+ ok(! system("rm -rf $srcdir $destdir"));
+ ok(! system("mkdir -p $srcdir"));
+}
+
+# At one point, changing the extension of the source file of a page caused
+# ikiwiki to fail.
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+setupiki("initial setup");
+ok(! system("mv $srcdir/foo.mdwn $srcdir/foo.txt"));
+refreshiki("changed extension of source file of page");
+ok(! system("mv $srcdir/foo.txt $srcdir/foo.mdwn"));
+refreshiki("changed extension of source file of page 2");
+
+# Conflicting page sources is sorta undefined behavior,
+# but should not crash ikiwiki.
+# Added when refreshing
+ok(! system("touch $srcdir/foo.txt"));
+refreshiki("conflicting page sources in refresh");
+# Present during setup
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+ok(! system("touch $srcdir/foo.txt"));
+setupiki("conflicting page sources in setup");
+
+# Page and non-page file with same pagename.
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+ok(! system("touch $srcdir/foo"));
+setupiki("conflicting page and non-page in setup");
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+setupiki("initial setup");
+ok(! system("touch $srcdir/foo"));
+refreshiki("conflicting page added (non-page already existing) in refresh");
+newsrcdir();
+ok(! system("touch $srcdir/foo"));
+setupiki("initial setup");
+ok(! system("touch $srcdir/foo.mdwn"));
+refreshiki("conflicting non-page added (page already existing) in refresh");
+
+# Page that renders to a file that is also a subdirectory holding another
+# file.
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+ok(! system("mkdir -p $srcdir/foo/index.html"));
+ok(! system("touch $srcdir/foo/index.html/bar.mdwn"));
+setupiki("conflicting page file and subdirectory");
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+ok(! system("mkdir -p $srcdir/foo/index.html"));
+ok(! system("touch $srcdir/foo/index.html/bar"));
+setupiki("conflicting page file and subdirectory 2");
+
+# Changing a page file into a non-page could also cause ikiwiki to fail.
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+setupiki("initial setup");
+ok(! system("mv $srcdir/foo.mdwn $srcdir/foo"));
+refreshiki("page file turned into non-page");
+
+# Changing a non-page file into a page could also cause ikiwiki to fail.
+newsrcdir();
+ok(! system("touch $srcdir/foo"));
+setupiki("initial setup");
+ok(! system("mv $srcdir/foo $srcdir/foo.mdwn"));
+refreshiki("non-page file turned into page");
+
+# What if a page renders to the same html file that a rawhtml file provides?
+# Added when refreshing
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+setupiki("initial setup");
+ok(! system("mkdir -p $srcdir/foo"));
+ok(! system("touch $srcdir/foo/index.html"));
+refreshiki("rawhtml file rendered same as existing page in refresh");
+# Moved when refreshing
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+setupiki("initial setup");
+ok(! system("mkdir -p $srcdir/foo"));
+ok(! system("mv $srcdir/foo.mdwn $srcdir/foo/index.html"));
+refreshiki("existing page moved to rawhtml file in refresh");
+# Inverse added when refreshing
+newsrcdir();
+ok(! system("mkdir -p $srcdir/foo"));
+ok(! system("touch $srcdir/foo/index.html"));
+setupiki("initial setup");
+ok(! system("touch $srcdir/foo.mdwn"));
+refreshiki("page rendered same as existing rawhtml file in refresh");
+# Inverse moved when refreshing
+newsrcdir();
+ok(! system("mkdir -p $srcdir/foo"));
+ok(! system("touch $srcdir/foo/index.html"));
+setupiki("initial setup");
+ok(! system("mv $srcdir/foo/index.html $srcdir/foo.mdwn"));
+refreshiki("rawhtml file moved to page in refresh");
+# Present during setup
+newsrcdir();
+ok(! system("touch $srcdir/foo.mdwn"));
+ok(! system("mkdir -p $srcdir/foo"));
+ok(! system("touch $srcdir/foo/index.html"));
+setupiki("rawhtml file rendered same as existing page in setup");
+
+# cleanup
+ok(! system("rm -rf t/tmp"));
diff --git a/t/crazy-badass-perl-bug.t b/t/crazy-badass-perl-bug.t
new file mode 100755
index 000000000..328a979c2
--- /dev/null
+++ b/t/crazy-badass-perl-bug.t
@@ -0,0 +1,20 @@
+#!/usr/bin/perl
+# DO NOT CHANGE ANYTHING IN THIS FILE.
+# THe crazy bug reproduced here will go away if any of the calls
+# to htmlize are changed.
+# Note: This was http://bugs.debian.org/376329 , and was fixed in
+# perl 5.14.
+use warnings;
+use strict;
+use Test::More tests => 102;
+use Encode;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# Initialize htmlscrubber plugin
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+IkiWiki::loadplugins(); IkiWiki::checkconfig();
+ok(IkiWiki::htmlize("foo", "foo", "mdwn", readfile("t/test1.mdwn")));
+ok(IkiWiki::htmlize("foo", "foo", "mdwn", readfile("t/test3.mdwn")),
+ "wtf?") for 1..100;
diff --git a/t/cvs.t b/t/cvs.t
new file mode 100755
index 000000000..cbac43252
--- /dev/null
+++ b/t/cvs.t
@@ -0,0 +1,708 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More; my $total_tests = 72;
+use IkiWiki;
+
+my $default_test_methods = '^test_*';
+my @required_programs = qw(
+ cvs
+ cvsps
+);
+my @required_modules = qw(
+ File::chdir
+ File::MimeInfo
+ Date::Parse
+ File::Temp
+ File::ReadBackwards
+);
+my $dir = "/tmp/ikiwiki-test-cvs.$$";
+
+# TESTS FOR GENERAL META-BEHAVIOR
+
+sub test_web_comments {
+ # how much of the web-edit workflow are we actually testing?
+ # because we want to test comments:
+ # - when the first comment for page.mdwn is added, and page/ is
+ # created to hold the comment, page/ isn't added to CVS control,
+ # so the comment isn't either
+ # - can't reproduce after chmod g+s ikiwiki.cgi (20120204)
+ # - side effect for moderated comments: after approval they
+ # show up normally AND are still pending, too
+ # - comments.pm treats rcs_commit_staged() as returning conflicts?
+}
+
+sub test_chdir_magic {
+ # when are we bothering with "local $CWD" and when aren't we?
+}
+
+sub test_cvs_info {
+ # inspect "Repository revision" (used in code)
+ # inspect "Sticky Options" (used in tests to verify existence of "-kb")
+}
+
+sub test_cvs_run_cvs {
+ # extract the stdout-redirect thing
+ # - prove that it silences stdout
+ # - prove that stderr comes through just fine
+ # prove that when cvs exits nonzero (fail), function exits false
+ # prove that when cvs exits zero (success), function exits true
+ # always pass -f, just in case
+ # steal from git.pm: safe_git(), run_or_{die,cry,non}
+ # - open() instead of system()
+ # always call cvs_run_cvs(), don't ever run 'cvs' directly
+ # - for cvs_info(), make it respect wantarray
+}
+
+sub test_cvs_run_cvsps {
+ # parameterize command like run_cvs()
+ # expose config vars for e.g. "--cvs-direct -z 30"
+ # always pass -x (unless proven otherwise)
+ # - but diff doesn't! optimization alert
+ # always pass -b HEAD (configurable like gitmaster_branch?)
+}
+
+sub test_cvs_parse_cvsps {
+ # extract method from rcs_recentchanges
+ # document expected changeset format
+ # document expected changeset delimiter
+ # try: cvsps -q -x -p && ls | sort -rn | head -100
+ # - benchmark against current impl (that uses File::ReadBackwards)
+}
+
+sub test_cvs_parse_log_accum {
+ # add new, preferred method for rcs_recentchanges to use
+ # teach log_accum to record commits (into transient?)
+ # script cvsps to bootstrap (or replace?) commit history
+ # teach ikiwiki-makerepo to set up log_accum and commit_prep
+ # why are NetBSD commit mails unreliable?
+ # - is it working for CVS commits and failing for web commits?
+}
+
+sub test_cvs_is_controlling {
+ # with no args:
+ # - if srcdir is in CVS, return true
+ # - else, return false
+ # with a dir arg:
+ # - if dir is in CVS, return true
+ # - else, return false
+ # with a file arg:
+ # - is there anything that wants the answer? if so, answer
+ # - else, die
+}
+
+
+# TESTS FOR GENERAL PLUGIN API CALLS
+
+sub test_checkconfig {
+ my $default_cvspath = 'ikiwiki';
+
+ undef $config{cvspath}; IkiWiki::checkconfig();
+ is(
+ $config{cvspath}, $default_cvspath,
+ q{can provide default cvspath},
+ );
+
+ $config{cvspath} = "/$default_cvspath/"; IkiWiki::checkconfig();
+ is(
+ $config{cvspath}, $default_cvspath,
+ q{can set typical cvspath and strip well-meaning slashes},
+ );
+
+ $config{cvspath} = "/$default_cvspath//subdir"; IkiWiki::checkconfig();
+ is(
+ $config{cvspath}, "$default_cvspath/subdir",
+ q{can really sanitize cvspath as assumed by rcs_recentchanges},
+ );
+
+ my $default_num_wrappers = @{$config{wrappers}};
+ undef $config{cvs_wrapper}; IkiWiki::checkconfig();
+ is(
+ @{$config{wrappers}}, $default_num_wrappers,
+ q{can start with no wrappers configured},
+ );
+
+ $config{cvs_wrapper} = $config{cvsrepo} . "/CVSROOT/post-commit";
+ IkiWiki::checkconfig();
+ is(
+ @{$config{wrappers}}, ++$default_num_wrappers,
+ q{can add cvs_wrapper},
+ );
+
+ undef $config{cvs_wrapper};
+ $config{cvspath} = $default_cvspath;
+ IkiWiki::checkconfig();
+}
+
+sub test_getsetup {
+ # anything worth testing?
+}
+
+sub test_genwrapper {
+ # testable directly? affects rcs_add, but are we exercising this?
+}
+
+
+# TESTS FOR VCS PLUGIN API CALLS
+
+sub test_rcs_update {
+ # can it assume we're under CVS control? or must it check?
+ # anything else worth testing?
+}
+
+sub test_rcs_prepedit {
+ # can it assume we're under CVS control? or must it check?
+ # for existing file, returns latest revision in repo
+ # - what's this used for? should it return latest revision in checkout?
+ # for new file, returns empty string
+
+ # netbsd web log says "could not open lock file"
+ # XXX does this work right?
+ # how about with un-added dirs in the srcdir?
+ # how about with cvsps.core lying around?
+}
+
+sub test_rcs_commit {
+ # can it assume we're under CVS control? or must it check?
+ # if someone else changed the page since rcs_prepedit was called:
+ # - try to merge into our working copy
+ # - if merge succeeds, proceed to commit
+ # - else, return page content with the conflict markers in it
+ # commit:
+ # - if success, return undef
+ # - else, revert + return content with the conflict markers in it
+ # git.pm receives "session" param -- useful here?
+ # web commits start with "web commit {by,from} "
+
+ # XXX commit can fail due to "could not open lock file"
+}
+
+sub test_rcs_commit_staged {
+ # if commit succeeds, return undef
+ # else, warn and return error message (really? or just non-undef?)
+}
+
+sub test_rcs_add {
+ my @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 0);
+
+ my $message = "add a top-level ASCII (non-UTF-8) page via VCS API";
+ my $file = q{test0.mdwn};
+ add_and_commit($file, $message, qq{# \$Id\$\n* some plain ASCII text});
+ is_newly_added($file);
+ is_in_keyword_substitution_mode($file, q{-kkv});
+ like(
+ readfile($config{srcdir} . "/$file"),
+ qr/^# \$Id: $file,v 1\.1 .+\$$/m,
+ q{can expand RCS Id keyword},
+ );
+ my $generated_file = $config{destdir} . q{/test0/index.html};
+ ok(-e $generated_file, q{post-commit hook generates content});
+ like(
+ readfile($generated_file),
+ qr/^<h1>\$Id: $file,v 1\.1 .+\$<\/h1>$/m,
+ q{can htmlize mdwn, including RCS Id},
+ );
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 1);
+ is_most_recent_change(\@changes, stripext($file), $message);
+
+ $message = "add a top-level dir via VCS API";
+ my $dir1 = q{test3};
+ can_mkdir($dir1);
+ IkiWiki::rcs_add($dir1);
+ # XXX test that the wrapper hangs here without our genwrapper()
+ # XXX test that the wrapper doesn't hang here with it
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 1); # despite the dir add
+ IkiWiki::rcs_commit(
+ file => $dir1,
+ message => $message,
+ token => "oom",
+ );
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 1); # dirs aren't tracked
+
+ $message = "add a non-ASCII (UTF-8) text file in an un-added dir";
+ can_mkdir($_) for (qw(test4 test4/test5));
+ $file = q{test4/test5/test1.mdwn};
+ add_and_commit($file, $message, readfile("t/test1.mdwn"));
+ is_newly_added($file);
+ is_in_keyword_substitution_mode($file, q{-kkv});
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 2);
+ is_most_recent_change(\@changes, stripext($file), $message);
+
+ $message = "add a binary file in an un-added dir, and commit_staged";
+ can_mkdir(q{test6});
+ $file = q{test6/test7.ico};
+ my $bindata_in = readfile("doc/favicon.ico", 1);
+ my $bindata_out = sub { readfile($config{srcdir} . "/$file", 1) };
+ writefile($file, $config{srcdir}, $bindata_in, 1);
+ is(&$bindata_out(), $bindata_in, q{binary files match before commit});
+ IkiWiki::rcs_add($file);
+ IkiWiki::rcs_commit_staged(message => $message);
+ is_newly_added($file);
+ is_in_keyword_substitution_mode($file, q{-kb});
+ is(&$bindata_out(), $bindata_in, q{binary files match after commit});
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 3);
+ is_most_recent_change(\@changes, $file, $message);
+ ok(
+ unlink($config{srcdir} . "/$file"),
+ q{can remove file in order to re-fetch it from repo},
+ );
+ ok(! -e $config{srcdir} . "/$file", q{really removed file});
+ IkiWiki::rcs_update();
+ is(&$bindata_out(), $bindata_in, q{binary files match after re-fetch});
+
+ $message = "add a UTF-8 and a binary file in different dirs";
+ my $file1 = "test8/test9.mdwn";
+ my $file2 = "test10/test11.ico";
+ can_mkdir($_) for (qw(test8 test10));
+ writefile($file1, $config{srcdir}, readfile('t/test2.mdwn'));
+ writefile($file2, $config{srcdir}, $bindata_in, 1);
+ IkiWiki::rcs_add($_) for ($file1, $file2);
+ IkiWiki::rcs_commit_staged(message => $message);
+ is_newly_added($_) for ($file1, $file2);
+ is_in_keyword_substitution_mode($file1, q{-kkv});
+ is_in_keyword_substitution_mode($file2, q{-kb});
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 3);
+ @changes = IkiWiki::rcs_recentchanges(4);
+ is_total_number_of_changes(\@changes, 4);
+ # XXX test for both files in the commit, and no other files
+ is_most_recent_change(\@changes, $file2, $message);
+
+ $message = "remove the UTF-8 and binary files we just added";
+ IkiWiki::rcs_remove($_) for ($file1, $file2);
+ IkiWiki::rcs_commit_staged(message => $message);
+ ok(! -d "$config{srcdir}/test8", q{empty dir pruned by post-commit});
+ ok(! -d "$config{srcdir}/test10", q{empty dir pruned by post-commit});
+ @changes = IkiWiki::rcs_recentchanges(11);
+ is_total_number_of_changes(\@changes, 5);
+ # XXX test for both files in the commit, and no other files
+ is_most_recent_change(\@changes, $file2, $message);
+
+ $message = "re-add UTF-8 and binary files with names swapped";
+ writefile($file2, $config{srcdir}, readfile('t/test2.mdwn'));
+ writefile($file1, $config{srcdir}, $bindata_in, 1);
+ IkiWiki::rcs_add($_) for ($file1, $file2);
+ IkiWiki::rcs_commit_staged(message => $message);
+ isnt_newly_added($_) for ($file1, $file2);
+ is_in_keyword_substitution_mode($file2, q{-kkv});
+ is_in_keyword_substitution_mode($file1, q{-kb});
+ @changes = IkiWiki::rcs_recentchanges(11);
+ is_total_number_of_changes(\@changes, 6);
+ # XXX test for both files in the commit, and no other files
+ is_most_recent_change(\@changes, $file2, $message);
+
+ # prevent web edits from attempting to create .../CVS/foo.mdwn
+ # on case-insensitive filesystems, also prevent .../cvs/foo.mdwn
+ # unless your "CVS" is something else and we've made it configurable
+ # also want a pre-commit hook for this?
+
+ # pre-commit hook:
+ # - lcase filenames
+ # - ?
+
+ # can it assume we're under CVS control? or must it check?
+}
+
+sub test_rcs_remove {
+ # can it assume we're under CVS control? or must it check?
+ # remove a top-level file
+ # - rcs_commit
+ # - inspect recentchanges: one new change, file removed
+ # remove two files (in different dirs)
+ # - rcs_commit_staged
+ # - inspect recentchanges: one new change, both files removed
+}
+
+sub test_rcs_rename {
+ # can it assume we're under CVS control? or must it check?
+ # rename a file in the same dir
+ # - rcs_commit_staged
+ # - inspect recentchanges: one new change, one file removed, one added
+ # rename a file into a different dir
+ # - rcs_commit_staged
+ # - inspect recentchanges: one new change, one file removed, one added
+ # rename a file into a not-yet-existing dir
+ # - rcs_commit_staged
+ # - inspect recentchanges: one new change, one file removed, one added
+ # is it safe to use "mv"? what if $dest is somehow outside the wiki?
+}
+
+sub test_rcs_recentchanges {
+ my @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 0);
+
+ my $message = "Add a page via CVS directly";
+ my $file = q{test2.mdwn};
+ writefile($file, $config{srcdir}, readfile(q{t/test2.mdwn}));
+ system "cd $config{srcdir}"
+ . " && cvs add $file >/dev/null 2>&1";
+ system "cd $config{srcdir}"
+ . " && cvs commit -m \"$message\" $file >/dev/null";
+
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 1);
+ is_most_recent_change(\@changes, stripext($file), $message);
+
+ # CVS commits run ikiwiki once for every committed file (!)
+ # - commit_prep alone should fix this
+ # CVS multi-dir commits show only the first dir in recentchanges
+ # - commit_prep might also fix this?
+ # CVS post-commit hook is amped off to avoid locking against itself
+ # - commit_prep probably doesn't fix this... but maybe?
+ # can it assume we're under CVS control? or must it check?
+ # don't worry whether we're called with a number (we always are)
+ # other rcs tests already inspect much of the returned structure
+ # CVS commits say "cvs" and get the right committer
+ # web commits say "web" and get the right committer
+ # - and don't start with "web commit {by,from} "
+ # "nickname" -- can we ever meaningfully set this?
+
+ # prefer log_accum, then cvsps, else die
+ # run the high-level recentchanges tests 2x (once for each method)
+ # - including in other test subs that check recentchanges?
+}
+
+sub test_rcs_diff {
+ my @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 0);
+
+ my $message = "add a UTF-8 and an ASCII file in different dirs";
+ my $file1 = "rcsdiff1/utf8.mdwn";
+ my $file2 = "rcsdiff2/ascii.mdwn";
+ my $contents2 = ''; $contents2 .= "$_. foo\n" for (1..11);
+ can_mkdir($_) for (qw(rcsdiff1 rcsdiff2));
+ writefile($file1, $config{srcdir}, readfile('t/test2.mdwn'));
+ writefile($file2, $config{srcdir}, $contents2);
+ IkiWiki::rcs_add($_) for ($file1, $file2);
+ IkiWiki::rcs_commit_staged(message => $message);
+
+ # XXX we rely on rcs_recentchanges() to be called first!
+ # XXX or else for no cvsps cache to exist yet...
+ # XXX because rcs_diff() doesn't pass -x (as an optimization)
+ @changes = IkiWiki::rcs_recentchanges(3);
+ is_total_number_of_changes(\@changes, 1);
+
+ unlike(
+ $changes[0]->{pages}->[0]->{diffurl},
+ qr/%2F/m,
+ q{path separators are preserved when UTF-8scaping filename},
+ );
+
+ my $changeset = 1;
+
+ my $maxlines = undef;
+ my $scalar_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
+ like(
+ $scalar_diffs,
+ qr/^\+11\. foo$/m,
+ q{unbounded scalar diffs go all the way to 11},
+ );
+ my @array_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
+ is(
+ $array_diffs[$#array_diffs],
+ "+11. foo\n",
+ q{unbounded array diffs go all the way to 11},
+ );
+
+ $maxlines = 8;
+ $scalar_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
+ unlike(
+ $scalar_diffs,
+ qr/^\+11\. foo$/m,
+ q{bounded scalar diffs don't go all the way to 11},
+ );
+ @array_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
+ isnt(
+ $array_diffs[$#array_diffs],
+ "+11. foo\n",
+ q{bounded array diffs don't go all the way to 11},
+ );
+ is(
+ scalar @array_diffs,
+ $maxlines,
+ q{bounded array diffs contain expected maximum number of lines},
+ );
+
+ # can it assume we're under CVS control? or must it check?
+}
+
+sub test_rcs_getctime {
+ # can it assume we're under CVS control? or must it check?
+ # given a file, find its creation time, else return 0
+ # first implement in the obvious way
+ # then cache
+}
+
+sub test_rcs_getmtime {
+ # can it assume we're under CVS control? or must it check?
+ # given a file, find its modification time, else return 0
+ # first implement in the obvious way
+ # then cache
+}
+
+sub test_rcs_receive {
+ my $description = q{rcs_receive doesn't make sense for CVS};
+ exists $IkiWiki::hooks{rcs}{rcs_receive}
+ ? fail($description)
+ : pass($description);
+}
+
+sub test_rcs_preprevert {
+ # can it assume we're under CVS control? or must it check?
+ # given a patchset number, return structure describing what'd happen:
+ # - see doc/plugins/write.mdwn:rcs_receive()
+ # don't forget about attachments
+}
+
+sub test_rcs_revert {
+ # test rcs_recentchanges() real darn well
+ # extract read-backwards patchset parser from rcs_recentchanges()
+ # recentchanges: given max, return list of changeset/files/etc.
+ # revert: given changeset ID, return list of file/rev/action
+ #
+ # can it assume we're under CVS control? or must it check?
+ # given a patchset number, stage the revert for rcs_commit_staged()
+ # if commit succeeds, return undef
+ # else, warn and return error message (really? or just non-undef?)
+}
+
+sub main {
+ my $test_methods = defined $ENV{TEST_METHOD}
+ ? $ENV{TEST_METHOD}
+ : $default_test_methods;
+
+ _startup($test_methods eq $default_test_methods);
+ _runtests(_get_matching_test_subs($test_methods));
+ _shutdown($test_methods eq $default_test_methods);
+}
+
+main();
+
+
+# INTERNAL SUPPORT ROUTINES
+
+sub _plan_for_test_more {
+ my $can_plan = shift;
+
+ foreach my $program (@required_programs) {
+ my $program_path = `which $program`;
+ chomp $program_path;
+ return plan(skip_all => "$program not available")
+ unless -x $program_path;
+ }
+
+ foreach my $module (@required_modules) {
+ eval qq{use $module};
+ return plan(skip_all => "$module not available")
+ if $@;
+ }
+
+ return plan(skip_all => "can't create $dir: $!")
+ unless mkdir($dir);
+ return plan(skip_all => "can't remove $dir: $!")
+ unless rmdir($dir);
+
+ return unless $can_plan;
+
+ return plan(tests => $total_tests);
+}
+
+# http://stackoverflow.com/questions/607282/whats-the-best-way-to-discover-all-subroutines-a-perl-module-has
+
+use B qw/svref_2object/;
+
+sub in_package {
+ my ($coderef, $package) = @_;
+ my $cv = svref_2object($coderef);
+ return if not $cv->isa('B::CV') or $cv->GV->isa('B::SPECIAL');
+ return $cv->GV->STASH->NAME eq $package;
+}
+
+sub list_module {
+ my $module = shift;
+ no strict 'refs';
+ return grep {
+ defined &{"$module\::$_"} and in_package(\&{*$_}, $module)
+ } keys %{"$module\::"};
+}
+
+
+# support for xUnit-style testing, a la Test::Class
+
+sub _startup {
+ my $can_plan = shift;
+ _plan_for_test_more($can_plan);
+ hook(type => "genwrapper", id => "cvstest", call => \&_wrapper_paths);
+ _generate_test_config();
+}
+
+sub _shutdown {
+ my $had_plan = shift;
+ done_testing() unless $had_plan;
+}
+
+sub _setup {
+ _generate_test_repo();
+}
+
+sub _teardown {
+ system "rm -rf $dir";
+}
+
+sub _runtests {
+ my @coderefs = (@_);
+ for (@coderefs) {
+ _setup();
+ $_->();
+ _teardown();
+ }
+}
+
+sub _get_matching_test_subs {
+ my $re = shift;
+ no strict 'refs';
+ return map { \&{*$_} } grep { /$re/ } sort(list_module('main'));
+}
+
+sub _generate_test_config {
+ %config = IkiWiki::defaultconfig();
+ $config{rcs} = "cvs";
+ $config{srcdir} = "$dir/src";
+ $config{allow_symlinks_before_srcdir} = 1;
+ $config{destdir} = "$dir/dest";
+ $config{cvsrepo} = "$dir/repo";
+ $config{cvspath} = "ikiwiki";
+ use Cwd; $config{templatedir} = getcwd() . '/templates';
+ $config{diffurl} = "/nonexistent/cvsweb/[[file]]";
+ IkiWiki::loadplugins();
+ IkiWiki::checkconfig();
+}
+
+sub _generate_test_repo {
+ die "can't create $dir: $!"
+ unless mkdir($dir);
+
+ my $cvs = "cvs -d $config{cvsrepo}";
+ my $dn = ">/dev/null";
+
+ system "$cvs init $dn";
+ system "mkdir $dir/$config{cvspath} $dn";
+ system "cd $dir/$config{cvspath} && "
+ . "$cvs import -m import $config{cvspath} VENDOR RELEASE $dn";
+ system "rm -rf $dir/$config{cvspath} $dn";
+ system "$cvs co -d $config{srcdir} $config{cvspath} $dn";
+
+ _generate_and_configure_post_commit_hook();
+}
+
+sub _generate_and_configure_post_commit_hook {
+ $config{cvs_wrapper} = $config{cvsrepo} . "/CVSROOT/test-post";
+ $config{wrapper} = $config{cvs_wrapper};
+
+ require IkiWiki::Wrapper;
+ {
+ no warnings 'once';
+ $IkiWiki::program_to_wrap = 'ikiwiki.out';
+ # XXX substitute its interpreter to Makefile's $(PERL)
+ # XXX best solution: do this to all scripts during build
+ }
+ IkiWiki::gen_wrapper();
+
+ my $cvs = "cvs -d $config{cvsrepo}";
+ my $dn = ">/dev/null";
+
+ system "mkdir $config{destdir} $dn";
+ system "cd $dir && $cvs co CVSROOT $dn && cd CVSROOT && " .
+ "echo 'DEFAULT $config{cvsrepo}/CVSROOT/test-post %{sVv} &' "
+ . " >> loginfo && "
+ . "$cvs commit -m 'test repo setup' $dn && "
+ . "cd .. && rm -rf CVSROOT";
+}
+
+sub add_and_commit {
+ my ($file, $message, $contents) = @_;
+ writefile($file, $config{srcdir}, $contents);
+ IkiWiki::rcs_add($file);
+ IkiWiki::rcs_commit(
+ file => $file,
+ message => $message,
+ token => "moo",
+ );
+}
+
+sub can_mkdir {
+ my $dir = shift;
+ ok(
+ mkdir($config{srcdir} . "/$dir"),
+ qq{can mkdir $dir},
+ );
+}
+
+sub is_newly_added { _newly_added_or_not(shift, 1) }
+sub isnt_newly_added { _newly_added_or_not(shift, 0) }
+sub _newly_added_or_not {
+ my ($file, $expected_new) = @_;
+ my ($func, $word);
+ if ($expected_new) {
+ $func = \&Test::More::is;
+ $word = q{is};
+ }
+ else {
+ $func = \&Test::More::isnt;
+ $word = q{isn't};
+ }
+ $func->(
+ IkiWiki::Plugin::cvs::cvs_info("Repository revision", $file),
+ '1.1',
+ qq{$file $word newly added to CVS},
+ );
+}
+
+sub is_in_keyword_substitution_mode {
+ my ($file, $mode) = @_;
+ is(
+ IkiWiki::Plugin::cvs::cvs_info("Sticky Options", $file),
+ $mode,
+ qq{$file is in CVS with expected keyword substitution mode},
+ );
+}
+
+sub is_total_number_of_changes {
+ my ($changes, $expected_total) = @_;
+ is(
+ $#{$changes},
+ $expected_total - 1,
+ qq{total commits == $expected_total},
+ );
+}
+
+sub is_most_recent_change {
+ my ($changes, $page, $message) = @_;
+ is(
+ $changes->[0]{message}[0]{"line"},
+ $message,
+ q{most recent commit's first message line matches},
+ );
+ is(
+ $changes->[0]{pages}[0]{"page"},
+ $page,
+ q{most recent commit's first pagename matches},
+ );
+}
+
+sub stripext {
+ my ($file, $extension) = @_;
+ $extension = '\..+?' unless defined $extension;
+ $file =~ s|$extension$||g;
+ return $file;
+}
+
+sub _wrapper_paths {
+ return qq{newenviron[i++]="PERL5LIB=$ENV{PERL5LIB}";};
+}
diff --git a/t/dirname.t b/t/dirname.t
new file mode 100755
index 000000000..197d00d63
--- /dev/null
+++ b/t/dirname.t
@@ -0,0 +1,12 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 6;
+
+BEGIN { use_ok("IkiWiki"); }
+
+is(IkiWiki::dirname("/home/joey/foo/bar"), "/home/joey/foo");
+is(IkiWiki::dirname("./foo"), ".");
+is(IkiWiki::dirname("baz"), "");
+is(IkiWiki::dirname("/tmp/"), "/tmp/");
+is(IkiWiki::dirname("/home/joey/foo/"), "/home/joey/foo/");
diff --git a/t/file_pruned.t b/t/file_pruned.t
new file mode 100755
index 000000000..34f366610
--- /dev/null
+++ b/t/file_pruned.t
@@ -0,0 +1,40 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 27;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+
+ok(IkiWiki::file_pruned(".htaccess"));
+ok(IkiWiki::file_pruned(".ikiwiki/"));
+ok(IkiWiki::file_pruned(".ikiwiki/index"));
+ok(IkiWiki::file_pruned("CVS/foo"));
+ok(IkiWiki::file_pruned("subdir/CVS/foo"));
+ok(IkiWiki::file_pruned(".svn"));
+ok(IkiWiki::file_pruned("subdir/.svn"));
+ok(IkiWiki::file_pruned("subdir/.svn/foo"));
+ok(IkiWiki::file_pruned(".git"));
+ok(IkiWiki::file_pruned("subdir/.git"));
+ok(IkiWiki::file_pruned("subdir/.git/foo"));
+ok(! IkiWiki::file_pruned("svn/fo"));
+ok(! IkiWiki::file_pruned("git"));
+ok(! IkiWiki::file_pruned("index.mdwn"));
+ok(! IkiWiki::file_pruned("index."));
+ok(IkiWiki::file_pruned("."));
+ok(IkiWiki::file_pruned("./"));
+
+# absolute filenames are not allowed.
+ok(IkiWiki::file_pruned("/etc/passwd"));
+ok(IkiWiki::file_pruned("//etc/passwd"));
+ok(IkiWiki::file_pruned("/"));
+ok(IkiWiki::file_pruned("//"));
+ok(IkiWiki::file_pruned("///"));
+
+
+ok(IkiWiki::file_pruned(".."));
+ok(IkiWiki::file_pruned("../"));
+
+ok(IkiWiki::file_pruned("y/foo.dpkg-tmp"));
+ok(IkiWiki::file_pruned("y/foo.ikiwiki-new"));
diff --git a/t/find_src_files.t b/t/find_src_files.t
new file mode 100755
index 000000000..a3742db75
--- /dev/null
+++ b/t/find_src_files.t
@@ -0,0 +1,97 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 20;
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Render"); }
+
+%config=IkiWiki::defaultconfig();
+$config{srcdir}="t/tmp/srcdir";
+$config{underlaydir}="t/tmp/underlaydir";
+IkiWiki::checkconfig();
+
+sub cleanup {
+ ok(! system("rm -rf t/tmp"));
+}
+
+sub setup_underlay {
+ foreach my $file (@_) {
+ writefile($file, $config{underlaydir}, "test content");
+ }
+ return @_;
+}
+
+sub setup_srcdir {
+ foreach my $file (@_) {
+ writefile($file, $config{srcdir}, "test content");
+ }
+ return @_;
+}
+
+sub test_src_files {
+ my %expected=map { $_ => 1 } @{shift()}; # the input list may have dups
+ my $desc=shift;
+
+ close STDERR; # find_src_files prints warnings about bad files
+
+ my ($files, $pages)=IkiWiki::find_src_files();
+ is_deeply([sort @$files], [sort keys %expected], $desc);
+}
+
+cleanup();
+
+my @list=setup_underlay(qw{index.mdwn sandbox.mdwn smiley.png ikiwiki.mdwn ikiwiki/directive.mdwn ikiwiki/directive/foo.mdwn});
+push @list, setup_srcdir(qw{index.mdwn foo.mwdn icon.jpeg blog/archive/1/2/3/foo.mdwn blog/archive/1/2/4/bar.mdwn blog/archive.mdwn});
+test_src_files(\@list, "simple test");
+
+setup_srcdir(".badfile");
+test_src_files(\@list, "srcdir dotfile is skipped");
+
+setup_underlay(".badfile");
+test_src_files(\@list, "underlay dotfile is skipped");
+
+setup_srcdir(".ikiwiki/index");
+test_src_files(\@list, "srcdir dotdir is skipped");
+
+setup_underlay(".ikiwiki/index");
+test_src_files(\@list, "underlay dotdir is skipped");
+
+setup_srcdir("foo>.mdwn");
+test_src_files(\@list, "illegal srcdir filename skipped");
+
+setup_underlay("foo>.mdwn");
+test_src_files(\@list, "illegal underlay filename skipped");
+
+system("mkdir -p $config{srcdir}/empty");
+test_src_files(\@list, "empty srcdir directory ignored");
+
+system("mkdir -p $config{underlaydir}/empty");
+test_src_files(\@list, "empty underlay directory ignored");
+
+setup_underlay("bad.mdwn");
+system("ln -sf /etc/passwd $config{srcdir}/bad.mdwn");
+test_src_files(\@list, "underlaydir override attack foiled");
+
+system("ln -sf /etc/passwd $config{srcdir}/symlink.mdwn");
+test_src_files(\@list, "file symlink in srcdir skipped");
+
+system("ln -sf /etc/passwd $config{underlaydir}/symlink.mdwn");
+test_src_files(\@list, "file symlink in underlaydir skipped");
+
+system("ln -sf /etc/ $config{srcdir}/symdir");
+test_src_files(\@list, "dir symlink in srcdir skipped");
+
+system("ln -sf /etc/ $config{underlaydir}/symdir");
+test_src_files(\@list, "dir symlink in underlaydir skipped");
+
+system("ln -sf /etc/ $config{srcdir}/blog/symdir");
+test_src_files(\@list, "deep dir symlink in srcdir skipped");
+
+system("ln -sf /etc/ $config{underlaydir}/ikiwiki/symdir");
+test_src_files(\@list, "deep dir symlink in underlaydir skipped");
+
+
+
+
+cleanup();
diff --git a/t/git.t b/t/git.t
new file mode 100755
index 000000000..6d847dfb0
--- /dev/null
+++ b/t/git.t
@@ -0,0 +1,97 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+my $dir;
+BEGIN {
+ $dir="/tmp/ikiwiki-test-git.$$";
+ my $git=`which git`;
+ chomp $git;
+ if (! -x $git) {
+ eval q{
+ use Test::More skip_all => "git not available"
+ }
+ }
+ if (! mkdir($dir)) {
+ die $@;
+ }
+}
+use Test::More tests => 18;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{rcs} = "git";
+$config{srcdir} = "$dir/src";
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+ok (mkdir($config{srcdir}));
+is (system("./ikiwiki-makerepo git $config{srcdir} $dir/repo"), 0);
+
+my @changes;
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 0); # counts for dummy commit during repo creation
+# ikiwiki-makerepo's first commit is setting up the .gitignore
+is($changes[0]{message}[0]{"line"}, "initial commit");
+is($changes[0]{pages}[0]{"page"}, ".gitignore");
+
+# Web commit
+my $test1 = readfile("t/test1.mdwn");
+writefile('test1.mdwn', $config{srcdir}, $test1);
+IkiWiki::rcs_add("test1.mdwn");
+IkiWiki::rcs_commit(
+ file => "test1.mdwn",
+ message => "Added the first page",
+ token => "moo",
+);
+
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 1);
+is($changes[0]{message}[0]{"line"}, "Added the first page");
+is($changes[0]{pages}[0]{"page"}, "test1");
+
+# Manual commit
+my $message = "Added the second page";
+
+my $test2 = readfile("t/test2.mdwn");
+writefile('test2.mdwn', $config{srcdir}, $test2);
+system "cd $config{srcdir}; git add test2.mdwn >/dev/null 2>&1";
+system "cd $config{srcdir}; git commit -m \"$message\" test2.mdwn >/dev/null 2>&1";
+system "cd $config{srcdir}; git push origin >/dev/null 2>&1";
+
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 2);
+is($changes[0]{message}[0]{"line"}, $message);
+is($changes[0]{pages}[0]{"page"}, "test2");
+
+is($changes[1]{pages}[0]{"page"}, "test1");
+
+# Renaming
+
+writefile('test3.mdwn', $config{srcdir}, $test1);
+IkiWiki::rcs_add("test3.mdwn");
+IkiWiki::rcs_rename("test3.mdwn", "test4.mdwn");
+IkiWiki::rcs_commit_staged(message => "Added the 4th page");
+
+@changes = IkiWiki::rcs_recentchanges(4);
+
+is($#changes, 3);
+is($changes[0]{pages}[0]{"page"}, "test4");
+
+ok(mkdir($config{srcdir}."/newdir"));
+IkiWiki::rcs_rename("test4.mdwn", "newdir/test5.mdwn");
+IkiWiki::rcs_commit_staged(message => "Added the 5th page");
+
+@changes = IkiWiki::rcs_recentchanges(4);
+
+is($#changes, 3);
+is($changes[0]{pages}[0]{"page"}, "newdir/test5");
+
+IkiWiki::rcs_remove("newdir/test5.mdwn");
+IkiWiki::rcs_commit_staged(message => "Remove the 5th page");
+
+system "rm -rf $dir";
diff --git a/t/html.t b/t/html.t
new file mode 100755
index 000000000..3faf44154
--- /dev/null
+++ b/t/html.t
@@ -0,0 +1,30 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More;
+
+my @pages;
+
+BEGIN {
+ @pages=qw(index features news plugins/map security);
+ if (! -x "/usr/bin/validate") {
+ plan skip_all => "/usr/bin/validate html validator not present";
+ }
+ else {
+ plan(tests => int @pages + 2);
+ }
+ use_ok("IkiWiki");
+}
+
+# Have to build the html pages first.
+# Note that just building them like this doesn't exersise all the possible
+# html that can be generated, in particular it misses some of the action
+# links at the top, etc.
+ok(system("make >/dev/null") == 0);
+
+foreach my $page (@pages) {
+ print "# Validating $page\n";
+ ok(system("validate html/$page.html") == 0);
+}
+
+# TODO: validate form output html
diff --git a/t/htmlbalance.t b/t/htmlbalance.t
new file mode 100755
index 000000000..e5a5db0ee
--- /dev/null
+++ b/t/htmlbalance.t
@@ -0,0 +1,23 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+BEGIN {
+ eval q{
+ use HTML::TreeBuilder;
+ };
+ if ($@) {
+ eval q{use Test::More skip_all => "HTML::TreeBuilder not available"};
+ }
+ else {
+ eval q{use Test::More tests => 7};
+ }
+ use_ok("IkiWiki::Plugin::htmlbalance");
+}
+
+is(IkiWiki::Plugin::htmlbalance::sanitize(content => "<br></br>"), "<br />");
+is(IkiWiki::Plugin::htmlbalance::sanitize(content => "<div><p b=\"c\">hello world</div>"), "<div><p b=\"c\">hello world</p></div>");
+is(IkiWiki::Plugin::htmlbalance::sanitize(content => "<a></a></a>"), "<a></a>");
+is(IkiWiki::Plugin::htmlbalance::sanitize(content => "<b>foo <a</b>"), "<b>foo </b>");
+is(IkiWiki::Plugin::htmlbalance::sanitize(content => "<b> foo <a</a></b>"), "<b> foo </b>");
+is(IkiWiki::Plugin::htmlbalance::sanitize(content => "a>"), "a&gt;");
diff --git a/t/htmlize.t b/t/htmlize.t
new file mode 100755
index 000000000..1569c8dcf
--- /dev/null
+++ b/t/htmlize.t
@@ -0,0 +1,85 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 31;
+use Encode;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# Initialize htmlscrubber plugin
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+is(IkiWiki::htmlize("foo", "foo", "mdwn", "foo\n\nbar\n"), "<p>foo</p>\n\n<p>bar</p>\n",
+ "basic");
+my $val=Encode::encode_utf8(IkiWiki::htmlize("foo", "foo", "mdwn", readfile("t/test1.mdwn")));
+ok($val =~/&oacute;/ && $val =~/óóóóó/, "utf8; bug #373203");
+ok(IkiWiki::htmlize("foo", "foo", "mdwn", readfile("t/test2.mdwn")),
+ "this file crashes markdown if it's fed in as decoded utf-8");
+
+sub gotcha {
+ my $html=IkiWiki::htmlize("foo", "foo", "mdwn", shift);
+ return $html =~ /GOTCHA/;
+}
+ok(!gotcha(q{<a href="javascript:alert('GOTCHA')">click me</a>}),
+ "javascript url");
+ok(!gotcha(q{<a href="jscript:alert('GOTCHA')">click me</a>}),
+ "jscript url");
+ok(!gotcha(q{<a href="vbscript:alert('GOTCHA')">click me</a>}),
+ "vbscrpt url");
+ok(!gotcha(q{<a href="java script:alert('GOTCHA')">click me</a>}),
+ "java-tab-script url");
+ok(!gotcha(q{<span style="&#x61;&#x6e;&#x79;&#x3a;&#x20;&#x65;&#x78;&#x70;&#x72;&#x65;&#x73;&#x73;&#x69;&#x6f;(GOTCHA)&#x6e;&#x28;&#x77;&#x69;&#x6e;&#x64;&#x6f;&#x77;&#x2e;&#x6c;&#x6f;&#x63;&#x61;&#x74;&#x69;&#x6f;&#x6e;&#x3d;&#x27;&#x68;&#x74;&#x74;&#x70;&#x3a;&#x2f;&#x2f;&#x65;&#x78;&#x61;&#x6d;&#x70;&#x6c;&#x65;&#x2e;&#x6f;&#x72;&#x67;&#x2f;&#x27;&#x29;">foo</span>}),
+ "entity-encoded CSS script test");
+ok(!gotcha(q{<span style="&#97;&#110;&#121;&#58;&#32;&#101;&#120;&#112;&#114;&#101;&#115;&#115;&#105;&#111;&#110;(GOTCHA)&#40;&#119;&#105;&#110;&#100;&#111;&#119;&#46;&#108;&#111;&#99;&#97;&#116;&#105;&#111;&#110;&#61;&#39;&#104;&#116;&#116;&#112;&#58;&#47;&#47;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#111;&#114;&#103;&#47;&#39;&#41;">foo</span>}),
+ "another entity-encoded CSS script test");
+ok(!gotcha(q{<script>GOTCHA</script>}),
+ "script tag");
+ok(!gotcha(q{<form action="javascript:alert('GOTCHA')">foo</form>}),
+ "form action with javascript");
+ok(!gotcha(q{<video poster="javascript:alert('GOTCHA')" href="foo.avi">foo</video>}),
+ "video poster with javascript");
+ok(!gotcha(q{<span style="background: url(javascript:window.location=GOTCHA)">a</span>}),
+ "CSS script test");
+ok(! gotcha(q{<img src="data:text/javascript;GOTCHA">}),
+ "data:text/javascript (jeez!)");
+ok(gotcha(q{<img src="data:image/png;base64,GOTCHA">}), "data:image/png");
+ok(gotcha(q{<img src="data:image/gif;base64,GOTCHA">}), "data:image/gif");
+ok(gotcha(q{<img src="data:image/jpeg;base64,GOTCHA">}), "data:image/jpeg");
+ok(gotcha(q{<p>javascript:alert('GOTCHA')</p>}),
+ "not javascript AFAIK (but perhaps some web browser would like to
+ be perverse and assume it is?)");
+ok(gotcha(q{<img src="javascript.png?GOTCHA">}), "not javascript");
+ok(gotcha(q{<a href="javascript.png?GOTCHA">foo</a>}), "not javascript");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<img alt="foo" src="foo.gif">}),
+ q{<img alt="foo" src="foo.gif">}, "img with alt tag allowed");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="http://google.com/">}),
+ q{<a href="http://google.com/">}, "absolute url allowed");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="foo.html">}),
+ q{<a href="foo.html">}, "relative url allowed");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<span class="foo">bar</span>}),
+ q{<span class="foo">bar</span>}, "class attribute allowed");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="aaa#foo">}),
+ q{<a href="aaa#foo">}, "simple anchor allowed");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="aaa#foo:bar">}),
+ q{<a href="aaa#foo:bar">}, "colon allowed in anchor");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="aaa?foo:bar">}),
+ q{<a href="aaa?foo:bar">}, "colon allowed in query string");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="foo:bar">}),
+ q{<a>}, "unknown protocol blocked");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="#foo">}),
+ q{<a href="#foo">}, "simple relative anchor allowed");
+is(IkiWiki::htmlize("foo", "foo", "mdwn",
+ q{<a href="#foo:bar">}),
+ q{<a href="#foo:bar">}, "colon in simple relative anchor allowed");
diff --git a/t/index.t b/t/index.t
new file mode 100755
index 000000000..392a167e9
--- /dev/null
+++ b/t/index.t
@@ -0,0 +1,161 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use IkiWiki;
+
+package IkiWiki; # use internal variables
+use Test::More tests => 31;
+
+$config{wikistatedir}="/tmp/ikiwiki-test.$$";
+system "rm -rf $config{wikistatedir}";
+
+ok(! loadindex(), "loading nonexistent index file");
+
+# Load standard plugins.
+ok(loadplugin("meta"), "meta plugin loaded");
+ok(loadplugin("mdwn"), "mdwn plugin loaded");
+
+# Set up a default state.
+$pagesources{"Foo"}="Foo.mdwn";
+$pagesources{"bar"}="bar.mdwn";
+$pagesources{"bar.png"}="bar.png";
+my $now=time();
+$pagemtime{"Foo"}=$now;
+$pagemtime{"bar"}=$now-1000;
+$pagemtime{"bar.png"}=$now;
+$pagectime{"Foo"}=$now;
+$pagectime{"bar"}=$now-100000;
+$pagectime{"bar.png"}=$now-100000;
+$renderedfiles{"Foo"}=["Foo.html"];
+$renderedfiles{"bar"}=["bar.html", "bar.rss", "sparkline-foo.gif"];
+$renderedfiles{"bar.png"}=["bar.png"];
+$links{"Foo"}=["bar.png"];
+$links{"bar"}=["Foo", "new-page"];
+$typedlinks{"bar"}={tag => {"Foo" => 1}};
+$links{"bar.png"}=[];
+$depends{"Foo"}={};
+$depends{"bar"}={"foo*" => 1};
+$depends{"bar.png"}={};
+$pagestate{"bar"}{meta}{title}="a page about bar";
+$pagestate{"bar"}{meta}{moo}="mooooo";
+# only loaded plugins save state, so this should not be saved out
+$pagestate{"bar"}{nosuchplugin}{moo}="mooooo";
+
+ok(saveindex(), "save index");
+ok(-s "$config{wikistatedir}/indexdb", "index file created");
+
+# Clear state.
+%oldrenderedfiles=%pagectime=();
+%pagesources=%pagemtime=%oldlinks=%links=%depends=%typedlinks=%oldtypedlinks=
+%destsources=%renderedfiles=%pagecase=%pagestate=();
+
+ok(loadindex(), "load index");
+is_deeply(\%pagesources, {
+ Foo => "Foo.mdwn",
+ bar => "bar.mdwn",
+ "bar.png" => "bar.png",
+}, "%pagesources loaded correctly");
+is_deeply(\%pagemtime, {
+ Foo => $now,
+ bar => $now-1000,
+ "bar.png" => $now,
+}, "%pagemtime loaded correctly");
+is_deeply(\%pagectime, {
+ Foo => $now,
+ bar => $now-100000,
+ "bar.png" => $now-100000,
+}, "%pagemtime loaded correctly");
+is_deeply(\%renderedfiles, {
+ Foo => ["Foo.html"],
+ bar => ["bar.html", "bar.rss", "sparkline-foo.gif"],
+ "bar.png" => ["bar.png"],
+}, "%renderedfiles loaded correctly");
+is_deeply(\%oldrenderedfiles, {
+ Foo => ["Foo.html"],
+ bar => ["bar.html", "bar.rss", "sparkline-foo.gif"],
+ "bar.png" => ["bar.png"],
+}, "%oldrenderedfiles loaded correctly");
+is_deeply(\%links, {
+ Foo => ["bar.png"],
+ bar => ["Foo", "new-page"],
+ "bar.png" => [],
+}, "%links loaded correctly");
+is_deeply(\%depends, {
+ Foo => {},
+ bar => {"foo*" => 1},
+ "bar.png" => {},
+}, "%depends loaded correctly");
+is_deeply(\%pagestate, {
+ bar => {
+ meta => {
+ title => "a page about bar",
+ moo => "mooooo",
+ },
+ },
+}, "%pagestate loaded correctly");
+is_deeply(\%pagecase, {
+ foo => "Foo",
+ bar => "bar",
+ "bar.png" => "bar.png"
+}, "%pagecase generated correctly");
+is_deeply(\%destsources, {
+ "Foo.html" => "Foo",
+ "bar.html" => "bar",
+ "bar.rss" => "bar",
+ "sparkline-foo.gif" => "bar",
+ "bar.png" => "bar.png",
+}, "%destsources generated correctly");
+is_deeply(\%typedlinks, {
+ bar => {tag => {"Foo" => 1}},
+}, "%typedlinks loaded correctly");
+is_deeply(\%oldtypedlinks, {
+ bar => {tag => {"Foo" => 1}},
+}, "%oldtypedlinks loaded correctly");
+
+# Clear state.
+%oldrenderedfiles=%pagectime=();
+%pagesources=%pagemtime=%oldlinks=%links=%depends=%typedlinks=%oldtypedlinks=
+%destsources=%renderedfiles=%pagecase=%pagestate=();
+
+# When state is loaded for a wiki rebuild, only ctime, oldrenderedfiles,
+# and pagesources are retained.
+$config{rebuild}=1;
+ok(loadindex(), "load index");
+is_deeply(\%pagesources, {
+ Foo => "Foo.mdwn",
+ bar => "bar.mdwn",
+ "bar.png" => "bar.png",
+}, "%pagesources loaded correctly");
+is_deeply(\%pagemtime, {
+}, "%pagemtime loaded correctly");
+is_deeply(\%pagectime, {
+ Foo => $now,
+ bar => $now-100000,
+ "bar.png" => $now-100000,
+}, "%pagemtime loaded correctly");
+is_deeply(\%renderedfiles, {
+}, "%renderedfiles loaded correctly");
+is_deeply(\%oldrenderedfiles, {
+ Foo => ["Foo.html"],
+ bar => ["bar.html", "bar.rss", "sparkline-foo.gif"],
+ "bar.png" => ["bar.png"],
+}, "%oldrenderedfiles loaded correctly");
+is_deeply(\%links, {
+}, "%links loaded correctly");
+is_deeply(\%depends, {
+}, "%depends loaded correctly");
+is_deeply(\%pagestate, {
+}, "%pagestate loaded correctly");
+is_deeply(\%pagecase, { # generated implicitly since pagesources is loaded
+ foo => "Foo",
+ bar => "bar",
+ "bar.png" => "bar.png"
+}, "%pagecase generated correctly");
+is_deeply(\%destsources, {
+}, "%destsources generated correctly");
+is_deeply(\%typedlinks, {
+}, "%typedlinks cleared correctly");
+is_deeply(\%oldtypedlinks, {
+}, "%oldtypedlinks cleared correctly");
+
+system "rm -rf $config{wikistatedir}";
diff --git a/t/linkify.t b/t/linkify.t
new file mode 100755
index 000000000..6dff0a029
--- /dev/null
+++ b/t/linkify.t
@@ -0,0 +1,112 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 32;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# Initialize link plugin
+%config=IkiWiki::defaultconfig();
+IkiWiki::loadplugins();
+
+my $prefix_directives;
+
+sub linkify ($$$$) {
+ my $lpage=shift;
+ my $page=shift;
+
+ my $content=shift;
+ my @existing_pages=@{shift()};
+
+ # This is what linkify and htmllink need set right now to work.
+ # This could change, if so, update it..
+ %IkiWiki::pagecase=();
+ %links=();
+ foreach my $p (@existing_pages) {
+ $IkiWiki::pagecase{lc $p}=$p;
+ $links{$p}=[];
+ $renderedfiles{"$p.mdwn"}=[$p];
+ $destsources{$p}="$p.mdwn";
+ }
+
+ %config=IkiWiki::defaultconfig();
+ $config{cgiurl}="http://somehost/ikiwiki.cgi";
+ $config{srcdir}=$config{destdir}="/dev/null"; # placate checkconfig
+ # currently coded for non usedirs mode (TODO: check both)
+ $config{usedirs}=0;
+ $config{prefix_directives}=$prefix_directives;
+
+ IkiWiki::checkconfig();
+
+ return IkiWiki::linkify($lpage, $page, $content);
+}
+
+sub links_to ($$) {
+ my $link=shift;
+ my $content=shift;
+
+ if ($content =~ m!<a href="[^"]*\Q$link\E[^"]*"\s*[^>]*>!) {
+ return 1;
+ }
+ else {
+ print STDERR "# expected link to $link in $content\n";
+ return;
+ }
+}
+
+sub not_links_to ($$) {
+ my $link=shift;
+ my $content=shift;
+
+ if ($content !~ m!<a href="[^"]*\Q$link\E[^"]*">!) {
+ return 1;
+ }
+ else {
+ print STDERR "# expected no link to $link in $content\n";
+ return;
+ }
+}
+
+sub links_text ($$) {
+ my $text=shift;
+ my $content=shift;
+
+ if ($content =~ m!>\Q$text\E</a>!) {
+ return 1;
+ }
+ else {
+ print STDERR "# expected link text $text in $content\n";
+ return;
+ }
+}
+
+# Tests that are the same for both styles of prefix directives.
+foreach $prefix_directives (0,1) {
+ ok(links_to("bar", linkify("foo", "foo", "link to [[bar]] ok", ["foo", "bar"])), "ok link");
+ ok(links_to("bar_baz", linkify("foo", "foo", "link to [[bar_baz]] ok", ["foo", "bar_baz"])), "ok link");
+ ok(not_links_to("bar", linkify("foo", "foo", "link to \\[[bar]] ok", ["foo", "bar"])), "escaped link");
+ ok(links_to("page=bar", linkify("foo", "foo", "link to [[bar]] ok", ["foo"])), "broken link");
+ ok(links_to("bar", linkify("foo", "foo", "link to [[baz]] and [[bar]] ok", ["foo", "baz", "bar"])), "dual links");
+ ok(links_to("baz", linkify("foo", "foo", "link to [[baz]] and [[bar]] ok", ["foo", "baz", "bar"])), "dual links");
+ ok(links_to("bar", linkify("foo", "foo", "link to [[some_page|bar]] ok", ["foo", "bar"])), "named link");
+ ok(links_text("some page", linkify("foo", "foo", "link to [[some_page|bar]] ok", ["foo", "bar"])), "named link text");
+ ok(links_text("0", linkify("foo", "foo", "link to [[0|bar]] ok", ["foo", "bar"])), "named link to 0");
+ ok(links_text("Some long, & complex page name.", linkify("foo", "foo", "link to [[Some_long,_&_complex_page_name.|bar]] ok, and this is not a link]] here", ["foo", "bar"])), "complex named link text");
+ ok(links_to("foo/bar", linkify("foo/item", "foo", "link to [[bar]] ok", ["foo", "foo/item", "foo/bar"])), "inline page link");
+ ok(links_to("bar", linkify("foo", "foo", "link to [[bar]] ok", ["foo", "foo/item", "foo/bar"])), "same except not inline");
+ ok(links_to("bar#baz", linkify("foo", "foo", "link to [[bar#baz]] ok", ["foo", "bar"])), "anchor link");
+}
+
+$prefix_directives=0;
+ok(not_links_to("some_page", linkify("foo", "foo", "link to [[some page]] ok", ["foo", "bar", "some_page"])),
+ "link with whitespace, without prefix_directives");
+ok(not_links_to("bar", linkify("foo", "foo", "link to [[some page|bar]] ok", ["foo", "bar"])),
+ "named link, with whitespace, without prefix_directives");
+
+$prefix_directives=1;
+ok(links_to("some_page", linkify("foo", "foo", "link to [[some page]] ok", ["foo", "bar", "some_page"])),
+ "link with whitespace");
+ok(links_to("bar", linkify("foo", "foo", "link to [[some page|bar]] ok", ["foo", "bar"])),
+ "named link, with whitespace");
+ok(links_text("some page", linkify("foo", "foo", "link to [[some page|bar]] ok", ["foo", "bar"])),
+ "named link text, with whitespace");
diff --git a/t/linkpage.t b/t/linkpage.t
new file mode 100755
index 000000000..8085de153
--- /dev/null
+++ b/t/linkpage.t
@@ -0,0 +1,13 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 7;
+
+BEGIN { use_ok("IkiWiki"); }
+
+is(linkpage("foo bar"), "foo_bar");
+is(linkpage("foo bar baz"), "foo_bar_baz");
+is(linkpage("foo bar/baz"), "foo_bar/baz");
+is(linkpage("foo bar&baz"), "foo_bar__38__baz");
+is(linkpage("foo bar & baz"), "foo_bar___38___baz");
+is(linkpage("foo bar_baz"), "foo_bar_baz");
diff --git a/t/map.t b/t/map.t
new file mode 100755
index 000000000..5d4713d6f
--- /dev/null
+++ b/t/map.t
@@ -0,0 +1,242 @@
+#!/usr/bin/perl
+package IkiWiki;
+
+use warnings;
+use strict;
+use Test::More;
+
+BEGIN {
+ unless (eval { require XML::Twig }) {
+ eval q{
+ use Test::More skip_all => "XML::Twig is not available"
+ }
+ }
+}
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Render"); }
+BEGIN { use_ok("IkiWiki::Plugin::map"); }
+BEGIN { use_ok("IkiWiki::Plugin::mdwn"); }
+
+ok(! system("rm -rf t/tmp; mkdir t/tmp"));
+
+$config{verbose} = 1;
+$config{srcdir} = 't/tmp';
+$config{underlaydir} = 't/tmp';
+$config{underlaydirbase} = '.';
+$config{templatedir} = 'templates';
+$config{usedirs} = 1;
+$config{htmlext} = 'html';
+$config{wiki_file_chars} = "-[:alnum:]+/.:_";
+$config{userdir} = "users";
+$config{tagbase} = "tags";
+$config{default_pageext} = "mdwn";
+$config{wiki_file_prune_regexps} = [qr/^\./];
+$config{autoindex_commit} = 0;
+
+is(checkconfig(), 1);
+
+%oldrenderedfiles=%pagectime=();
+%pagesources=%pagemtime=%oldlinks=%links=%depends=%typedlinks=%oldtypedlinks=
+%destsources=%renderedfiles=%pagecase=%pagestate=();
+
+my @pages = qw(
+alpha
+alpha/1
+alpha/1/i
+alpha/1/ii
+alpha/1/iii
+alpha/1/iv
+alpha/2
+alpha/2/a
+alpha/2/b
+alpha/3
+beta
+);
+
+foreach my $page (@pages) {
+ # we use a non-default extension for these, so they're distinguishable
+ # from programmatically-created pages
+ $pagesources{$page} = "$page.mdwn";
+ $destsources{$page} = "$page.mdwn";
+ $pagemtime{$page} = $pagectime{$page} = 1000000;
+ writefile("$page.mdwn", "t/tmp", "your ad here");
+}
+
+sub comment {
+ my $str = shift;
+ $str =~ s/^/# /gm;
+ print $str;
+}
+
+sub node {
+ my $name = shift;
+ my $kids = shift;
+ my %stuff = @_;
+
+ return { %stuff, name => $name, kids => $kids };
+}
+
+sub check_nodes {
+ my $ul = shift;
+ my $expected = shift;
+
+ is($ul->tag, 'ul');
+
+ # expected is a list of hashes
+ # ul is a list of li
+ foreach my $li ($ul->children) {
+ my @kids = $li->children;
+
+ is($li->tag, 'li');
+
+ my $expectation = shift @$expected;
+
+ is($kids[0]->tag, 'a');
+ my $a = $kids[0];
+
+ if ($expectation->{parent}) {
+ is($a->att('class'), 'mapparent');
+ }
+ else {
+ is($a->att('class'), 'mapitem');
+ }
+
+ is_deeply([$a->text], [$expectation->{name}]);
+
+ if (@{$expectation->{kids}}) {
+ is(scalar @kids, 2);
+
+ check_nodes($kids[1], $expectation->{kids});
+ }
+ else {
+ is_deeply([@kids], [$a]);
+ }
+ }
+}
+
+sub check {
+ my $pagespec = shift;
+ my $expected = shift;
+ comment("*** $pagespec ***\n");
+
+ my $html = IkiWiki::Plugin::map::preprocess(pages => $pagespec,
+ page => 'map',
+ destpage => 'map');
+ comment($html);
+ my $tree = XML::Twig->new(pretty_print => 'indented');
+ eval {
+ $tree->parse($html);
+ };
+ if ($@) {
+ print "malformed XML: $@\n$html\n";
+ ok(0);
+ }
+ my $fragment = $tree->root;
+
+ is($fragment->tag, 'div');
+ is($fragment->att('class'), 'map');
+
+ if (@$expected) {
+ check_nodes(($fragment->children)[0], $expected);
+ }
+ else {
+ ok(! $fragment->children);
+ }
+
+ $tree->dispose;
+}
+
+check('doesnotexist', []);
+
+check('alpha', [node('alpha', [])]);
+
+check('alpha/*',
+ [
+ node('1', [
+ node('i', []),
+ node('ii', []),
+ node('iii', []),
+ node('iv', []),
+ ]),
+ node('2', [
+ node('a', []),
+ node('b', []),
+ ]),
+ node('3', []),
+ ]);
+
+check('alpha or alpha/*',
+ [
+ node('alpha', [
+ node('1', [
+ node('i', []),
+ node('ii', []),
+ node('iii', []),
+ node('iv', []),
+ ]),
+ node('2', [
+ node('a', []),
+ node('b', []),
+ ]),
+ node('3', []),
+ ]),
+ ]);
+
+check('alpha or alpha/1 or beta',
+ [
+ node('alpha', [
+ node('1', []),
+ ]),
+ node('beta', []),
+ ]);
+
+check('alpha/1 or beta',
+ [
+ node('alpha', [
+ node('1', []),
+ ], parent => 1),
+ node('beta', []),
+ ]);
+
+check('alpha/1/i* or alpha/2/a or beta',
+ [
+ node('alpha', [
+ node('1', [
+ node('i', []),
+ node('ii', []),
+ node('iii', []),
+ node('iv', []),
+ ], parent => 1),
+ node('2', [
+ node('a', []),
+ ], parent => 1),
+ ], parent => 1),
+ node('beta', []),
+ ]);
+
+check('alpha/1/i* or alpha/2/a',
+ [
+ node('1', [
+ node('i', []),
+ node('ii', []),
+ node('iii', []),
+ node('iv', []),
+ ], parent => 1),
+ node('2', [
+ node('a', []),
+ ], parent => 1),
+ ]);
+
+check('alpha/1/i*',
+ [
+ node('i', []),
+ node('ii', []),
+ node('iii', []),
+ node('iv', []),
+ ]);
+
+ok(! system("rm -rf t/tmp"));
+done_testing;
+
+1;
diff --git a/t/mercurial.t b/t/mercurial.t
new file mode 100755
index 000000000..4918fc76e
--- /dev/null
+++ b/t/mercurial.t
@@ -0,0 +1,75 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+my $dir;
+BEGIN {
+ $dir = "/tmp/ikiwiki-test-hg.$$";
+ my $hg=`which hg`;
+ chomp $hg;
+ if (! -x $hg) {
+ eval q{
+ use Test::More skip_all => "hg not available"
+ }
+ }
+ if (! mkdir($dir)) {
+ die $@;
+ }
+}
+use Test::More tests => 11;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{rcs} = "mercurial";
+$config{srcdir} = "$dir/repo";
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+use CGI::Session;
+my $session=CGI::Session->new;
+$session->param("name", "Joe User");
+
+system "hg init $config{srcdir}";
+
+# Web commit
+my $test1 = readfile("t/test1.mdwn");
+writefile('test1.mdwn', $config{srcdir}, $test1);
+IkiWiki::rcs_add("test1.mdwn");
+IkiWiki::rcs_commit(
+ file => "test1.mdwn",
+ message => "Added the first page",
+ token => "moo",
+ session => $session,
+);
+
+my @changes;
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 0);
+is($changes[0]{message}[0]{"line"}, "Added the first page");
+is($changes[0]{pages}[0]{"page"}, "test1");
+is($changes[0]{user}, "Joe User");
+
+# Manual commit
+my $username = "Foo Bar";
+my $user = "$username <foo.bar\@example.com>";
+my $message = "Added the second page";
+
+my $test2 = readfile("t/test2.mdwn");
+writefile('test2.mdwn', $config{srcdir}, $test2);
+system "hg add -R $config{srcdir} $config{srcdir}/test2.mdwn";
+system "hg commit -R $config{srcdir} -u \"$user\" -m \"$message\" -d \"0 0\"";
+
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 1);
+is($changes[0]{message}[0]{"line"}, $message);
+is($changes[0]{user}, $username);
+is($changes[0]{pages}[0]{"page"}, "test2");
+
+is($changes[1]{pages}[0]{"page"}, "test1");
+
+my $ctime = IkiWiki::rcs_getctime("test2.mdwn");
+is($ctime, 0);
+
+system "rm -rf $dir";
diff --git a/t/openiduser.t b/t/openiduser.t
new file mode 100755
index 000000000..746090103
--- /dev/null
+++ b/t/openiduser.t
@@ -0,0 +1,42 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+BEGIN {
+ eval q{
+ use Net::OpenID::VerifiedIdentity;
+ };
+ if ($@) {
+ eval q{use Test::More skip_all => "Net::OpenID::VerifiedIdentity not available"};
+ }
+ else {
+ eval q{use Test::More tests => 11};
+ }
+ use_ok("IkiWiki");
+}
+
+# Some typical examples:
+
+# This test, when run by Test::Harness using perl -w, exposes a warning in
+# Net::OpenID::VerifiedIdentity. Normally that warning is not displayed, as
+# that module does not use warnings. To avoid cluttering the test output,
+# disable the -w switch temporarily.
+$^W=0;
+is(IkiWiki::openiduser('http://josephturian.blogspot.com'), 'josephturian [blogspot.com]');
+$^W=1;
+
+is(IkiWiki::openiduser('http://yam655.livejournal.com/'), 'yam655 [livejournal.com]');
+is(IkiWiki::openiduser('http://id.mayfirst.org/jamie/'), 'jamie [id.mayfirst.org]');
+
+# yahoo has an anchor in the url
+is(IkiWiki::openiduser('https://me.yahoo.com/joeyhess#35f22'), 'joeyhess [me.yahoo.com]');
+# google urls are horrendous, but the worst bit is after a ?, so can be dropped
+is(IkiWiki::openiduser('https://www.google.com/accounts/o8/id?id=AItOawm-ebiIfxbKD3KNa-Cu9LvvD9edMLW7BAo'), 'id [www.google.com/accounts/o8]');
+
+# and some less typical ones taken from the ikiwiki commit history
+
+is(IkiWiki::openiduser('http://thm.id.fedoraproject.org/'), 'thm [id.fedoraproject.org]');
+is(IkiWiki::openiduser('http://dtrt.org/'), 'dtrt.org');
+is(IkiWiki::openiduser('http://alcopop.org/me/openid/'), 'openid [alcopop.org/me]');
+is(IkiWiki::openiduser('http://id.launchpad.net/882/bielawski1'), 'bielawski1 [id.launchpad.net/882]');
+is(IkiWiki::openiduser('http://technorati.com/people/technorati/drajt'), 'drajt [technorati.com/people/technorati]');
diff --git a/t/pagename.t b/t/pagename.t
new file mode 100755
index 000000000..540d10f4c
--- /dev/null
+++ b/t/pagename.t
@@ -0,0 +1,35 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 19;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# define mdwn as an extension
+$IkiWiki::hooks{htmlize}{mdwn}={};
+is(pagetype("foo.mdwn"), "mdwn");
+is(pagename("foo.mdwn"), "foo");
+is(pagetype("foo/bar.mdwn"), "mdwn");
+is(pagename("foo/bar.mdwn"), "foo/bar");
+
+# bare files get the full filename as page name, undef type
+is(pagetype("foo.png"), undef);
+is(pagename("foo.png"), "foo.png");
+is(pagetype("foo/bar.png"), undef);
+is(pagename("foo/bar.png"), "foo/bar.png");
+is(pagetype("foo"), undef);
+is(pagename("foo"), "foo");
+
+# keepextension preserves the extension in the page name
+$IkiWiki::hooks{htmlize}{txt}={keepextension => 1};
+is(pagename("foo.txt"), "foo.txt");
+is(pagetype("foo.txt"), "txt");
+is(pagename("foo/bar.txt"), "foo/bar.txt");
+is(pagetype("foo/bar.txt"), "txt");
+
+# noextension makes extensionless files be treated as first-class pages
+$IkiWiki::hooks{htmlize}{Makefile}={noextension =>1};
+is(pagetype("Makefile"), "Makefile");
+is(pagename("Makefile"), "Makefile");
+is(pagetype("foo/Makefile"), "Makefile");
+is(pagename("foo/Makefile"), "foo/Makefile");
diff --git a/t/pagespec_match.t b/t/pagespec_match.t
new file mode 100755
index 000000000..a37b06e8e
--- /dev/null
+++ b/t/pagespec_match.t
@@ -0,0 +1,147 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 87;
+
+BEGIN { use_ok("IkiWiki"); }
+
+ok(pagespec_match("foo", "*"));
+ok(!pagespec_match("foo", ""));
+ok(pagespec_match("foo", "!bar"));
+ok(pagespec_match("page", "?ag?"));
+ok(! pagespec_match("page", "?a?g?"));
+ok(pagespec_match("foo.png", "*.*"));
+ok(! pagespec_match("foo", "*.*"));
+ok(pagespec_match("foo", "foo or bar"), "simple list");
+ok(pagespec_match("bar", "foo or bar"), "simple list 2");
+ok(pagespec_match("foo", "f?? and !foz"));
+ok(! pagespec_match("foo", "f?? and !foo"));
+ok(! pagespec_match("foo", "* and !foo"));
+ok(! pagespec_match("foo", "foo and !foo"));
+ok(! pagespec_match("foo.png", "* and !*.*"));
+ok(pagespec_match("foo", "(bar or ((meep and foo) or (baz or foo) or beep))"));
+ok(pagespec_match("foo", "(
+ bar
+ or (
+ (meep and foo)
+ or
+ (baz or foo)
+ or beep
+ )
+)"), "multiline complex pagespec");
+ok(! pagespec_match("a/foo", "foo", location => "a/b"), "nonrelative fail");
+ok(! pagespec_match("foo", "./*", location => "a/b"), "relative fail");
+ok(pagespec_match("a/foo", "./*", location => "a/b"), "relative");
+ok(pagespec_match("a/b/foo", "./*", location => "a/b"), "relative 2");
+ok(pagespec_match("a/foo", "./*", "a/b"), "relative oldstyle call");
+ok(pagespec_match("foo", "./*", location => "a"), "relative toplevel");
+ok(pagespec_match("foo/bar", "*", location => "baz"), "absolute");
+ok(! pagespec_match("foo", "foo and bar"), "foo and bar");
+ok(pagespec_match("{f}oo", "{*}*"), "curly match");
+ok(! pagespec_match("foo", "{*}*"), "curly !match");
+
+ok(pagespec_match("somepage", "user(frodo)", user => "frodo"));
+ok(pagespec_match("somepage", "user(frodo)", user => "Frodo"));
+ok(! pagespec_match("somepage", "user(frodo)", user => "Sam"));
+ok(pagespec_match("somepage", "user(*o)", user => "Bilbo"));
+ok(pagespec_match("somepage", "user(*o)", user => "frodo"));
+ok(! pagespec_match("somepage", "user(*o)", user => "Sam"));
+ok(pagespec_match("somepage", "user(http://*.myopenid.com/)", user => "http://foo.myopenid.com/"));
+ok(pagespec_match("somepage", "user(*://*)", user => "http://foo.myopenid.com/"));
+
+# The link and backlink stuff needs this.
+$config{userdir}="";
+$links{foo}=[qw{bar baz}];
+$links{bar}=[];
+$links{baz}=[];
+$links{meh}=[];
+$links{"bugs/foo"}=[qw{bugs/done}];
+$links{"bugs/done"}=[];
+$links{"bugs/bar"}=[qw{done}];
+$links{"done"}=[];
+$links{"done"}=[];
+$links{"examples/softwaresite/bugs/fails_to_frobnicate"}=[qw{done}];
+$links{"examples/softwaresite/bugs/done"}=[];
+$links{"ook"}=[qw{/blog/tags/foo}];
+foreach my $p (keys %links) {
+ $pagesources{$p}="$p.mdwn";
+}
+$pagesources{"foo.png"}="foo.png";
+$pagesources{"foo"}="foo.mdwn";
+$IkiWiki::hooks{htmlize}{mdwn}={};
+
+ok(pagespec_match("foo", "foo"), "simple");
+ok(! pagespec_match("foo", "bar"), "simple fail");
+ok(pagespec_match("foo", "foo"), "simple glob");
+ok(pagespec_match("foo", "f*"), "simple glob fail");
+ok(pagespec_match("foo", "page(foo)"), "page()");
+print pagespec_match("foo", "page(foo)")."\n";
+ok(! pagespec_match("foo", "page(bar)"), "page() fail");
+ok(! pagespec_match("foo.png", "page(foo.png)"), "page() fails on non-page");
+ok(! pagespec_match("foo.png", "page(foo*)"), "page() fails on non-page glob");
+ok(pagespec_match("foo", "page(foo)"), "page() glob");
+ok(pagespec_match("foo", "page(f*)"), "page() glob fail");
+ok(pagespec_match("foo", "link(bar)"), "link");
+ok(pagespec_match("foo", "link(.)", location => "bar"), "link with .");
+ok(! pagespec_match("foo", "link(.)"), "link with . but missing location");
+ok(pagespec_match("foo", "link(ba?)"), "glob link");
+ok(! pagespec_match("foo", "link(quux)"), "failed link");
+ok(! pagespec_match("foo", "link(qu*)"), "failed glob link");
+ok(pagespec_match("bugs/foo", "link(done)", location => "bugs/done"), "link match to bestlink");
+ok(! pagespec_match("examples/softwaresite/bugs/done", "link(done)",
+ location => "bugs/done"), "link match to bestlink");
+ok(pagespec_match("examples/softwaresite/bugs/fails_to_frobnicate",
+ "link(./done)", location => "examples/softwaresite/bugs/done"), "link relative");
+ok(! pagespec_match("foo", "link(./bar)", location => "foo/bar"), "link relative fail");
+ok(pagespec_match("bar", "backlink(foo)"), "backlink");
+ok(! pagespec_match("quux", "backlink(foo)"), "failed backlink");
+ok(! pagespec_match("bar", ""), "empty pagespec should match nothing");
+ok(! pagespec_match("bar", " "), "blank pagespec should match nothing");
+ok(pagespec_match("ook", "link(blog/tags/foo)"), "link internal absolute success");
+ok(pagespec_match("ook", "link(/blog/tags/foo)"), "link explicit absolute success");
+ok(pagespec_match("meh", "!link(done)"), "negated failing match is a success");
+
+$ENV{TZ}="GMT";
+$IkiWiki::pagectime{foo}=1154532692; # Wed Aug 2 11:26 EDT 2006
+$IkiWiki::pagectime{bar}=1154532695; # after
+ok(pagespec_match("foo", "created_before(bar)"));
+ok(! pagespec_match("foo", "created_after(bar)"));
+ok(! pagespec_match("bar", "created_before(foo)"));
+ok(pagespec_match("bar", "created_after(foo)"));
+ok(pagespec_match("foo", "creation_year(2006)"), "year");
+ok(! pagespec_match("foo", "creation_year(2005)"), "other year");
+ok(pagespec_match("foo", "creation_month(8)"), "month");
+ok(! pagespec_match("foo", "creation_month(9)"), "other month");
+ok(pagespec_match("foo", "creation_day(2)"), "day");
+ok(! pagespec_match("foo", "creation_day(3)"), "other day");
+
+ok(! pagespec_match("foo", "no_such_function(foo)"), "foo");
+
+my $ret=pagespec_match("foo", "(invalid");
+ok(! $ret, "syntax error");
+ok($ret =~ /syntax error/, "error message");
+
+$ret=pagespec_match("foo", "bar or foo");
+ok($ret, "simple match");
+is($ret, "foo matches foo", "stringified return");
+
+my $i=pagespec_match("foo", "link(bar)")->influences;
+is(join(",", keys %$i), 'foo', "link is influenced by the page with the link");
+$i=pagespec_match("bar", "backlink(foo)")->influences;
+is(join(",", keys %$i), 'foo', "backlink is influenced by the page with the link");
+$i=pagespec_match("bar", "backlink(foo)")->influences;
+is(join(",", keys %$i), 'foo', "backlink is influenced by the page with the link");
+$i=pagespec_match("bar", "created_before(foo)")->influences;
+is(join(",", keys %$i), 'foo', "created_before is influenced by the comparison page");
+$i=pagespec_match("bar", "created_after(foo)")->influences;
+is(join(",", keys %$i), 'foo', "created_after is influenced by the comparison page");
+$i=pagespec_match("foo", "link(baz) and created_after(bar)")->influences;
+is(join(",", sort keys %$i), 'bar,foo', "influences add up over AND");
+$i=pagespec_match("foo", "link(baz) and created_after(bar)")->influences;
+is(join(",", sort keys %$i), 'bar,foo', "influences add up over OR");
+$i=pagespec_match("foo", "!link(baz) and !created_after(bar)")->influences;
+is(join(",", sort keys %$i), 'bar,foo', "influences unaffected by negation");
+$i=pagespec_match("foo", "!link(baz) and !created_after(bar)")->influences;
+is(join(",", sort keys %$i), 'bar,foo', "influences unaffected by negation");
+$i=pagespec_match("meh", "!link(done)")->influences;
+is(join(",", sort keys %$i), 'meh', "a negated, failing link test is successful, so the page is a link influence");
diff --git a/t/pagespec_match_list.t b/t/pagespec_match_list.t
new file mode 100755
index 000000000..7ff178aad
--- /dev/null
+++ b/t/pagespec_match_list.t
@@ -0,0 +1,174 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 126;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+IkiWiki::checkconfig();
+
+{
+ package IkiWiki::SortSpec;
+
+ sub cmp_raw_path { $a cmp $b }
+}
+
+%pagesources=(
+ foo => "foo.mdwn",
+ foo2 => "foo2.mdwn",
+ foo3 => "foo3.mdwn",
+ bar => "bar.mdwn",
+ "post/1" => "post/1.mdwn",
+ "post/2" => "post/2.mdwn",
+ "post/3" => "post/3.mdwn",
+);
+$IkiWiki::pagectime{foo} = 2;
+$IkiWiki::pagectime{foo2} = 2;
+$IkiWiki::pagectime{foo3} = 1;
+$IkiWiki::pagectime{foo4} = 1;
+$IkiWiki::pagectime{foo5} = 1;
+$IkiWiki::pagectime{bar} = 3;
+$IkiWiki::pagectime{"post/1"} = 6;
+$IkiWiki::pagectime{"post/2"} = 6;
+$IkiWiki::pagectime{"post/3"} = 6;
+$links{foo}=[qw{post/1 post/2}];
+$links{foo2}=[qw{bar}];
+$links{foo3}=[qw{bar}];
+
+is_deeply([pagespec_match_list("foo", "bar")], ["bar"]);
+is_deeply([sort(pagespec_match_list("foo", "* and !post/*"))], ["bar", "foo", "foo2", "foo3"]);
+is_deeply([sort(pagespec_match_list("foo", "post/*"))], ["post/1", "post/2", "post/3"]);
+is_deeply([pagespec_match_list("foo", "post/*", sort => "title")],
+ ["post/1", "post/2", "post/3"]);
+is_deeply([pagespec_match_list("foo", "post/*", sort => "title", reverse => 1)],
+ ["post/3", "post/2", "post/1"]);
+is_deeply([pagespec_match_list("foo", "post/*", sort => "title", num => 2)],
+ ["post/1", "post/2"]);
+is_deeply([pagespec_match_list("foo", "post/*", sort => "title", num => 50)],
+ ["post/1", "post/2", "post/3"]);
+is_deeply([pagespec_match_list("foo", "post/*", sort => "title", num => 50, reverse => 1)],
+ ["post/3", "post/2", "post/1"]);
+is_deeply([pagespec_match_list("foo", "post/*", sort => "title",
+ filter => sub { $_[0] =~ /3/}) ],
+ ["post/1", "post/2"]);
+is_deeply([pagespec_match_list("foo", "*", sort => "raw_path", num => 2)],
+ ["bar", "foo"]);
+is_deeply([pagespec_match_list("foo", "foo* or bar*",
+ sort => "-age title")], # oldest first, break ties by title
+ ["foo3", "foo", "foo2", "bar"]);
+my $r=eval { pagespec_match_list("foo", "beep") };
+ok(eval { pagespec_match_list("foo", "beep") } == 0);
+ok(! $@, "does not fail with error when unable to match anything");
+eval { pagespec_match_list("foo", "this is not a legal pagespec!") };
+ok($@, "fails with error when pagespec bad");
+
+# A pagespec that requires page metadata should add influences
+# as an explicit dependency. In the case of a link, a links dependency.
+foreach my $spec ("* and link(bar)", "* or link(bar)") {
+ pagespec_match_list("foo2", $spec, deptype => deptype("presence"));
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_PRESENCE);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_LINKS)));
+ ok($IkiWiki::depends_simple{foo2}{foo2} == $IkiWiki::DEPEND_LINKS);
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+ pagespec_match_list("foo3", $spec, deptype => deptype("links"));
+ ok($IkiWiki::depends{foo3}{$spec} & $IkiWiki::DEPEND_LINKS);
+ ok(! ($IkiWiki::depends{foo3}{$spec} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_PRESENCE)));
+ ok($IkiWiki::depends_simple{foo3}{foo3} == $IkiWiki::DEPEND_LINKS);
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+}
+
+# A link pagespec is influenced by the pages that currently contain the link.
+# It is not influced by pages that do not currently contain the link,
+# because if those pages were changed to contain it, regular dependency
+# handling would be triggered.
+foreach my $spec ("* and link(bar)", "link(bar)", "no_such_page or link(bar)") {
+ pagespec_match_list("foo2", $spec);
+ ok($IkiWiki::depends_simple{foo2}{foo2} == $IkiWiki::DEPEND_LINKS);
+ ok(! exists $IkiWiki::depends_simple{foo2}{foo}, $spec);
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+}
+
+# Oppositely, a pagespec that tests for pages that do not have a link
+# is not influenced by pages that currently contain the link, but
+# is instead influenced by pages that currently do not (but that
+# could be changed to have it).
+foreach my $spec ("* and !link(bar)", "* and !(!(!link(bar)))") {
+ pagespec_match_list("foo2", $spec);
+ ok(! exists $IkiWiki::depends_simple{foo2}{foo2});
+ ok($IkiWiki::depends_simple{foo2}{foo} == $IkiWiki::DEPEND_LINKS, $spec);
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+}
+
+# a pagespec with backlinks() will add as an influence the page with the links
+foreach my $spec ("bar or (backlink(foo) and !*.png)", "backlink(foo)", "!backlink(foo)") {
+ pagespec_match_list("foo2", $spec, deptype => deptype("presence"));
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_PRESENCE);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_LINKS)));
+ ok($IkiWiki::depends_simple{foo2}{foo} == $IkiWiki::DEPEND_LINKS);
+ ok(! exists $IkiWiki::depends_simple{foo2}{foo2});
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+ pagespec_match_list("foo2", $spec, deptype => deptype("links"));
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_LINKS);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & ($IkiWiki::DEPEND_PRESENCE | $IkiWiki::DEPEND_CONTENT)));
+ ok($IkiWiki::depends_simple{foo2}{foo} == $IkiWiki::DEPEND_LINKS);
+ ok(! exists $IkiWiki::depends_simple{foo2}{foo2});
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+ pagespec_match_list("foo2", $spec, deptype => deptype("presence", "links"));
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_PRESENCE);
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_LINKS);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_CONTENT));
+ ok($IkiWiki::depends_simple{foo2}{foo} == $IkiWiki::DEPEND_LINKS);
+ ok(! exists $IkiWiki::depends_simple{foo2}{foo2});
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+ pagespec_match_list("foo2", $spec);
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_CONTENT);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & ($IkiWiki::DEPEND_PRESENCE | $IkiWiki::DEPEND_LINKS)));
+ ok($IkiWiki::depends_simple{foo2}{foo} == $IkiWiki::DEPEND_LINKS);
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+}
+
+# Hard fails due to a glob, etc, will block influences of other anded terms.
+foreach my $spec ("nosuchpage and link(bar)", "link(bar) and nosuchpage",
+ "link(bar) and */Discussion", "*/Discussion and link(bar)",
+ "!foo2 and link(bar)", "link(bar) and !foo2") {
+ pagespec_match_list("foo2", $spec, deptype => deptype("presence"));
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_PRESENCE);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_LINKS)));
+ ok(! exists $IkiWiki::depends_simple{foo2}{foo2}, "no influence from $spec");
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+}
+
+# A hard fail will not block influences of other ored terms.
+foreach my $spec ("nosuchpage or link(bar)", "link(bar) or nosuchpage",
+ "link(bar) or */Discussion", "*/Discussion or link(bar)",
+ "!foo2 or link(bar)", "link(bar) or !foo2",
+ "link(bar) or (!foo2 and !foo1)") {
+ pagespec_match_list("foo2", $spec, deptype => deptype("presence"));
+ ok($IkiWiki::depends{foo2}{$spec} & $IkiWiki::DEPEND_PRESENCE);
+ ok(! ($IkiWiki::depends{foo2}{$spec} & ($IkiWiki::DEPEND_CONTENT | $IkiWiki::DEPEND_LINKS)));
+ ok($IkiWiki::depends_simple{foo2}{foo2} == $IkiWiki::DEPEND_LINKS);
+ %IkiWiki::depends_simple=();
+ %IkiWiki::depends=();
+}
+
+my @ps;
+foreach my $p (100..500) {
+ $IkiWiki::pagectime{"p/$p"} = $p;
+ $pagesources{"p/$p"} = "p/$p.mdwn";
+ unshift @ps, "p/$p";
+}
+is_deeply([pagespec_match_list("foo", "p/*", sort => "age")],
+ [@ps]);
+is_deeply([pagespec_match_list("foo", "p/*", sort => "age", num => 20)],
+ [@ps[0..19]]);
diff --git a/t/pagespec_match_result.t b/t/pagespec_match_result.t
new file mode 100755
index 000000000..13fcdcad0
--- /dev/null
+++ b/t/pagespec_match_result.t
@@ -0,0 +1,84 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 138;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# Note that new objects have to be constructed freshly for each test, since
+# object states are mutated as they are combined.
+sub S { IkiWiki::SuccessReason->new("match", @_) }
+sub F { IkiWiki::FailReason->new("no match", @_) }
+sub E { IkiWiki::ErrorReason->new("error in matching", @_) }
+
+ok(S() eq "match");
+ok(F() eq "no match");
+ok(E() eq "error in matching");
+
+ok(S());
+ok(! F());
+ok(! E());
+
+ok(!(! S()));
+ok(!(!(! F)));
+ok(!(!(! E)));
+
+ok(S() | F());
+ok(F() | S());
+ok(!(F() | E()));
+ok(!(!S() | F() | E()));
+
+ok(S() & S() & S());
+ok(!(S() & E()));
+ok(!(S() & F()));
+ok(!(S() & F() & E()));
+ok(S() & (F() | F() | S()));
+
+# influence merging tests
+foreach my $test (
+ ['$s | $f' => 1], # OR merges
+ ['! $s | ! $f' => 1], # OR merges with negated terms too
+ ['!(!(!$s)) | $f' => 1],# OR merges with multiple negation too
+ ['$s | $f | E()' => 1], # OR merges, even though E() has no influences
+ ['$s | E() | $f' => 1], # ditto
+ ['E() | $s | $f' => 1], # ditto
+ ['!$s | !$f | E()' => 1],# negated terms also do not block merges
+ ['!$s | E() | $f' => 1],# ditto
+ ['E() | $s | !$f' => 1],# ditto
+ ['$s & $f' => 1], # AND merges if both items have influences
+ ['!$s & $f' => 1], # AND merges negated terms too
+ ['$s & !$f' => 1], # AND merges negated terms too
+ ['$s & $f & E()' => 0], # AND fails to merge since E() has no influences
+ ['$s & E() & $f' => 0], # ditto
+ ['E() & $s & $f' => 0], # ditto
+ ) {
+ my $op=$test->[0];
+ my $influence=$test->[1];
+
+ my $s=S(foo => 1, bar => 1);
+ is($s->influences->{foo}, 1);
+ is($s->influences->{bar}, 1);
+ my $f=F(bar => 2, baz => 1);
+ is($f->influences->{bar}, 2);
+ is($f->influences->{baz}, 1);
+ my $c = eval $op;
+ ok(ref $c);
+ if ($influence) {
+ is($c->influences->{foo}, 1, "foo ($op)");
+ is($c->influences->{bar}, (1 | 2), "bar ($op)");
+ is($c->influences->{baz}, 1, "baz ($op)");
+ }
+ else {
+ ok(! %{$c->influences}, "no influence for ($op)");
+ }
+}
+
+my $s=S(foo => 0, bar => 1);
+$s->influences(baz => 1);
+ok(! $s->influences->{foo}, "removed 0 influence");
+ok(! $s->influences->{bar}, "removed 1 influence");
+ok($s->influences->{baz}, "set influence");
+ok($s->influences_static);
+$s=S(foo => 0, bar => 1);
+$s->influences(baz => 1, "" => 1);
+ok(! $s->influences_static);
diff --git a/t/pagetitle.t b/t/pagetitle.t
new file mode 100755
index 000000000..d9aa62063
--- /dev/null
+++ b/t/pagetitle.t
@@ -0,0 +1,13 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 7;
+
+BEGIN { use_ok("IkiWiki"); }
+
+is(pagetitle("foo_bar"), "foo bar");
+is(pagetitle("foo_bar_baz"), "foo bar baz");
+is(pagetitle("foo_bar__33__baz"), "foo bar&#33;baz");
+is(pagetitle("foo_bar__1234__baz"), "foo bar&#1234;baz");
+is(pagetitle("foo_bar___33___baz"), "foo bar &#33; baz");
+is(pagetitle("foo_bar___95___baz"), "foo bar &#95; baz");
diff --git a/t/parentlinks.t b/t/parentlinks.t
new file mode 100755
index 000000000..9b4654903
--- /dev/null
+++ b/t/parentlinks.t
@@ -0,0 +1,81 @@
+#!/usr/bin/perl
+# -*- cperl-indent-level: 8; -*-
+# Testcases for the Ikiwiki parentlinks plugin.
+
+use warnings;
+use strict;
+use Test::More 'no_plan';
+
+my %expected;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# Init
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+$config{underlaydir}="underlays/basewiki";
+$config{templatedir}="t/parentlinks/templates";
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+# Test data
+$expected{'parentlinks'} =
+ {
+ "ikiwiki" => [],
+ "ikiwiki/pagespec" =>
+ [ {depth => 0, height => 2, },
+ {depth => 1, height => 1, },
+ ],
+ "ikiwiki/pagespec/attachment" =>
+ [ {depth => 0, height => 3, depth_0 => 1, height_3 => 1},
+ {depth => 1, height => 2, },
+ {depth => 2, height => 1, },
+ ],
+ };
+
+# Test function
+sub test_loop($$) {
+ my $loop=shift;
+ my $expected=shift;
+ my $template;
+ my %params;
+
+ ok($template=template('parentlinks.tmpl'), "template created");
+ ok($params{template}=$template, "params populated");
+
+ while ((my $page, my $exp) = each %{$expected}) {
+ my @path=(split("/", $page));
+ my $pagedepth=@path;
+ my $msgprefix="$page $loop";
+
+ # manually run the plugin hook
+ $params{page}=$page;
+ $template->clear_params();
+ IkiWiki::Plugin::parentlinks::pagetemplate(%params);
+ my $res=$template->param($loop);
+
+ is(scalar(@$res), $pagedepth, "$msgprefix: path length");
+ # logic & arithmetic validation tests
+ for (my $i=0; $i<$pagedepth; $i++) {
+ my $r=$res->[$i];
+ is($r->{height}, $pagedepth - $r->{depth},
+ "$msgprefix\[$i\]: height = pagedepth - depth");
+ ok($r->{depth} ge 0, "$msgprefix\[$i\]: depth>=0");
+ ok($r->{height} ge 0, "$msgprefix\[$i\]: height>=0");
+ }
+ # comparison tests, iff the test-suite has been written
+ if (scalar(@$exp) eq $pagedepth) {
+ for (my $i=0; $i<$pagedepth; $i++) {
+ my $e=$exp->[$i];
+ my $r=$res->[$i];
+ map { is($r->{$_}, $e->{$_}, "$msgprefix\[$i\]: $_"); } keys %$e;
+ }
+ }
+ # else {
+ # diag("Testsuite is incomplete for ($page,$loop); cannot run comparison tests.");
+ # }
+ }
+}
+
+# Main
+test_loop('parentlinks', $expected{'parentlinks'});
diff --git a/t/parentlinks/templates/parentlinks.tmpl b/t/parentlinks/templates/parentlinks.tmpl
new file mode 100644
index 000000000..3ca3b0030
--- /dev/null
+++ b/t/parentlinks/templates/parentlinks.tmpl
@@ -0,0 +1,4 @@
+<!-- This template file only has to "use" the loops tested by parentlinks.t -->
+
+<TMPL_LOOP NAME="PARENTLINKS">
+</TMPL_LOOP>
diff --git a/t/permalink.t b/t/permalink.t
new file mode 100755
index 000000000..36be984c5
--- /dev/null
+++ b/t/permalink.t
@@ -0,0 +1,14 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More 'no_plan';
+
+ok(! system("rm -rf t/tmp"));
+ok(! system("mkdir t/tmp"));
+ok(! system("make -s ikiwiki.out"));
+ok(! system("perl -I. ./ikiwiki.out -plugin inline -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates t/tinyblog t/tmp/out"));
+# This guid should never, ever change, for any reason whatsoever!
+my $guid="http://example.com/post/";
+ok(length `egrep '<guid.*>$guid</guid>' t/tmp/out/index.rss`);
+ok(length `egrep '<id>$guid</id>' t/tmp/out/index.atom`);
+ok(! system("rm -rf t/tmp t/tinyblog/.ikiwiki"));
diff --git a/t/po.t b/t/po.t
new file mode 100755
index 000000000..5e251fce4
--- /dev/null
+++ b/t/po.t
@@ -0,0 +1,250 @@
+#!/usr/bin/perl
+# -*- cperl-indent-level: 8; -*-
+use warnings;
+use strict;
+use File::Temp qw{tempdir};
+
+BEGIN {
+ unless (eval { require Locale::Po4a::Chooser }) {
+ eval q{
+ use Test::More skip_all => "Locale::Po4a::Chooser::new is not available"
+ }
+ }
+ unless (eval { require Locale::Po4a::Po }) {
+ eval q{
+ use Test::More skip_all => "Locale::Po4a::Po::new is not available"
+ }
+ }
+}
+
+use Test::More tests => 114;
+
+BEGIN { use_ok("IkiWiki"); }
+
+my $msgprefix;
+
+my $dir = tempdir("ikiwiki-test-po.XXXXXXXXXX",
+ DIR => File::Spec->tmpdir,
+ CLEANUP => 1);
+
+### Init
+%config=IkiWiki::defaultconfig();
+$config{srcdir} = "$dir/src";
+$config{destdir} = "$dir/dst";
+$config{destdir} = "$dir/dst";
+$config{underlaydirbase} = "/dev/null";
+$config{underlaydir} = "/dev/null";
+$config{url} = "http://example.com";
+$config{cgiurl} = "http://example.com/ikiwiki.cgi";
+$config{discussion} = 0;
+$config{po_master_language} = { code => 'en',
+ name => 'English'
+ };
+$config{po_slave_languages} = {
+ es => 'Castellano',
+ fr => "Français"
+ };
+$config{po_translatable_pages}='index or test1 or test2 or translatable';
+$config{po_link_to}='negotiated';
+IkiWiki::loadplugins();
+ok(IkiWiki::loadplugin('meta'), "meta plugin loaded");
+ok(IkiWiki::loadplugin('po'), "po plugin loaded");
+IkiWiki::checkconfig();
+
+### seed %pagesources and %pagecase
+$pagesources{'index'}='index.mdwn';
+$pagesources{'index.fr'}='index.fr.po';
+$pagesources{'index.es'}='index.es.po';
+$pagesources{'test1'}='test1.mdwn';
+$pagesources{'test1.es'}='test1.es.po';
+$pagesources{'test1.fr'}='test1.fr.po';
+$pagesources{'test2'}='test2.mdwn';
+$pagesources{'test2.es'}='test2.es.po';
+$pagesources{'test2.fr'}='test2.fr.po';
+$pagesources{'test3'}='test3.mdwn';
+$pagesources{'test3.es'}='test3.es.mdwn';
+$pagesources{'translatable'}='translatable.mdwn';
+$pagesources{'translatable.fr'}='translatable.fr.po';
+$pagesources{'translatable.es'}='translatable.es.po';
+$pagesources{'nontranslatable'}='nontranslatable.mdwn';
+foreach my $page (keys %pagesources) {
+ $IkiWiki::pagecase{lc $page}=$page;
+}
+
+### populate srcdir
+writefile('index.mdwn', $config{srcdir},
+ "[[!meta title=\"index title\"]]\n[[translatable]] [[nontranslatable]]");
+writefile('test1.mdwn', $config{srcdir},
+ "[[!meta title=\"test1 title\"]]\ntest1 content");
+writefile('test2.mdwn', $config{srcdir}, 'test2 content');
+writefile('test3.mdwn', $config{srcdir}, 'test3 content');
+writefile('translatable.mdwn', $config{srcdir}, '[[nontranslatable]]');
+writefile('nontranslatable.mdwn', $config{srcdir}, '[[/]] [[translatable]]');
+
+### istranslatable/istranslation
+# we run these tests twice because memoization attempts made them
+# succeed once every two tries...
+foreach (1, 2) {
+ok(IkiWiki::Plugin::po::istranslatable('index'), "index is translatable");
+ok(IkiWiki::Plugin::po::istranslatable('/index'), "/index is translatable");
+ok(! IkiWiki::Plugin::po::istranslatable('index.fr'), "index.fr is not translatable");
+ok(! IkiWiki::Plugin::po::istranslatable('index.es'), "index.es is not translatable");
+ok(! IkiWiki::Plugin::po::istranslatable('/index.fr'), "/index.fr is not translatable");
+ok(! IkiWiki::Plugin::po::istranslation('index'), "index is not a translation");
+ok(IkiWiki::Plugin::po::istranslation('index.fr'), "index.fr is a translation");
+ok(IkiWiki::Plugin::po::istranslation('index.es'), "index.es is a translation");
+ok(IkiWiki::Plugin::po::istranslation('/index.fr'), "/index.fr is a translation");
+ok(IkiWiki::Plugin::po::istranslatable('test1'), "test1 is translatable");
+ok(IkiWiki::Plugin::po::istranslation('test1.es'), "test1.es is a translation");
+ok(IkiWiki::Plugin::po::istranslation('test1.fr'), "test1.fr is a translation");
+ok(IkiWiki::Plugin::po::istranslatable('test2'), "test2 is translatable");
+ok(! IkiWiki::Plugin::po::istranslation('test2'), "test2 is not a translation");
+ok(! IkiWiki::Plugin::po::istranslatable('test3'), "test3 is not translatable");
+ok(! IkiWiki::Plugin::po::istranslation('test3'), "test3 is not a translation");
+}
+
+### pofiles
+
+my @pofiles = IkiWiki::Plugin::po::pofiles(srcfile("index.mdwn"));
+ok( @pofiles, "pofiles is defined");
+ok( @pofiles == 2, "pofiles has correct size");
+is_deeply(\@pofiles, ["$config{srcdir}/index.es.po", "$config{srcdir}/index.fr.po"], "pofiles content is correct");
+
+### links
+require IkiWiki::Render;
+
+sub refresh_n_scan(@) {
+ my @masterfiles_rel=@_;
+ foreach my $masterfile_rel (@masterfiles_rel) {
+ my $masterfile=srcfile($masterfile_rel);
+ IkiWiki::scan($masterfile_rel);
+ next unless IkiWiki::Plugin::po::istranslatable(pagename($masterfile_rel));
+ my @pofiles=IkiWiki::Plugin::po::pofiles($masterfile);
+ IkiWiki::Plugin::po::refreshpot($masterfile);
+ IkiWiki::Plugin::po::refreshpofiles($masterfile, @pofiles);
+ map IkiWiki::scan(IkiWiki::abs2rel($_, $config{srcdir})), @pofiles;
+ }
+}
+
+$config{po_link_to}='negotiated';
+$msgprefix="links (po_link_to=negotiated)";
+refresh_n_scan('index.mdwn', 'translatable.mdwn', 'nontranslatable.mdwn');
+is_deeply(\@{$links{'index'}}, ['translatable', 'nontranslatable'], "$msgprefix index");
+is_deeply(\@{$links{'index.es'}}, ['translatable.es', 'nontranslatable'], "$msgprefix index.es");
+is_deeply(\@{$links{'index.fr'}}, ['translatable.fr', 'nontranslatable'], "$msgprefix index.fr");
+is_deeply(\@{$links{'translatable'}}, ['nontranslatable'], "$msgprefix translatable");
+is_deeply(\@{$links{'translatable.es'}}, ['nontranslatable'], "$msgprefix translatable.es");
+is_deeply(\@{$links{'translatable.fr'}}, ['nontranslatable'], "$msgprefix translatable.fr");
+is_deeply(\@{$links{'nontranslatable'}}, ['/', 'translatable', 'translatable.fr', 'translatable.es'], "$msgprefix nontranslatable");
+
+$config{po_link_to}='current';
+$msgprefix="links (po_link_to=current)";
+refresh_n_scan('index.mdwn', 'translatable.mdwn', 'nontranslatable.mdwn');
+is_deeply(\@{$links{'index'}}, ['translatable', 'nontranslatable'], "$msgprefix index");
+is_deeply(\@{$links{'index.es'}}, [ (map bestlink('index.es', $_), ('translatable.es', 'nontranslatable'))], "$msgprefix index.es");
+is_deeply(\@{$links{'index.fr'}}, [ (map bestlink('index.fr', $_), ('translatable.fr', 'nontranslatable'))], "$msgprefix index.fr");
+is_deeply(\@{$links{'translatable'}}, [bestlink('translatable', 'nontranslatable')], "$msgprefix translatable");
+is_deeply(\@{$links{'translatable.es'}}, ['nontranslatable'], "$msgprefix translatable.es");
+is_deeply(\@{$links{'translatable.fr'}}, ['nontranslatable'], "$msgprefix translatable.fr");
+is_deeply(\@{$links{'nontranslatable'}}, ['/', 'translatable', 'translatable.fr', 'translatable.es'], "$msgprefix nontranslatable");
+
+### targetpage
+$config{usedirs}=0;
+$msgprefix="targetpage (usedirs=0)";
+is(targetpage('test1', 'html'), 'test1.en.html', "$msgprefix test1");
+is(targetpage('test1.fr', 'html'), 'test1.fr.html', "$msgprefix test1.fr");
+$config{usedirs}=1;
+$msgprefix="targetpage (usedirs=1)";
+is(targetpage('index', 'html'), 'index.en.html', "$msgprefix index");
+is(targetpage('index.fr', 'html'), 'index.fr.html', "$msgprefix index.fr");
+is(targetpage('test1', 'html'), 'test1/index.en.html', "$msgprefix test1");
+is(targetpage('test1.fr', 'html'), 'test1/index.fr.html', "$msgprefix test1.fr");
+is(targetpage('test3', 'html'), 'test3/index.html', "$msgprefix test3 (non-translatable page)");
+is(targetpage('test3.es', 'html'), 'test3.es/index.html', "$msgprefix test3.es (non-translatable page)");
+
+### urlto -> index
+$config{po_link_to}='current';
+$msgprefix="urlto (po_link_to=current)";
+is(urlto('', 'index'), './index.en.html', "$msgprefix index -> ''");
+is(urlto('', 'nontranslatable'), '../index.en.html', "$msgprefix nontranslatable -> ''");
+is(urlto('', 'translatable.fr'), '../index.fr.html', "$msgprefix translatable.fr -> ''");
+# when asking for a semi-absolute or absolute URL, we can't know what the
+# current language is, so for translatable pages we use the master language
+is(urlto('nontranslatable'), '/nontranslatable/', "$msgprefix 1-arg -> nontranslatable");
+is(urlto('translatable'), '/translatable/index.en.html', "$msgprefix 1-arg -> translatable");
+is(urlto('nontranslatable', undef, 1), 'http://example.com/nontranslatable/', "$msgprefix 1-arg -> nontranslatable");
+is(urlto('index', undef, 1), 'http://example.com/index.en.html', "$msgprefix 1-arg -> index");
+is(urlto('', undef, 1), 'http://example.com/index.en.html', "$msgprefix 1-arg -> ''");
+# FIXME: should these three produce the negotiatable URL instead of the master
+# language?
+is(urlto(''), '/index.en.html', "$msgprefix 1-arg -> ''");
+is(urlto('index'), '/index.en.html', "$msgprefix 1-arg -> index");
+is(urlto('translatable', undef, 1), 'http://example.com/translatable/index.en.html', "$msgprefix 1-arg -> translatable");
+
+$config{po_link_to}='negotiated';
+$msgprefix="urlto (po_link_to=negotiated)";
+is(urlto('', 'index'), './', "$msgprefix index -> ''");
+is(urlto('', 'nontranslatable'), '../', "$msgprefix nontranslatable -> ''");
+is(urlto('', 'translatable.fr'), '../', "$msgprefix translatable.fr -> ''");
+is(urlto('nontranslatable'), '/nontranslatable/', "$msgprefix 1-arg -> nontranslatable");
+is(urlto('translatable'), '/translatable/', "$msgprefix 1-arg -> translatable");
+is(urlto(''), '/', "$msgprefix 1-arg -> ''");
+is(urlto('index'), '/', "$msgprefix 1-arg -> index");
+is(urlto('nontranslatable', undef, 1), 'http://example.com/nontranslatable/', "$msgprefix 1-arg -> nontranslatable");
+is(urlto('translatable', undef, 1), 'http://example.com/translatable/', "$msgprefix 1-arg -> translatable");
+is(urlto('index', undef, 1), 'http://example.com/', "$msgprefix 1-arg -> index");
+is(urlto('', undef, 1), 'http://example.com/', "$msgprefix 1-arg -> ''");
+
+### bestlink
+$config{po_link_to}='current';
+$msgprefix="bestlink (po_link_to=current)";
+is(bestlink('test1.fr', 'test2'), 'test2.fr', "$msgprefix test1.fr -> test2");
+is(bestlink('test1.fr', 'test2.es'), 'test2.es', "$msgprefix test1.fr -> test2.es");
+$config{po_link_to}='negotiated';
+$msgprefix="bestlink (po_link_to=negotiated)";
+is(bestlink('test1.fr', 'test2'), 'test2.fr', "$msgprefix test1.fr -> test2");
+is(bestlink('test1.fr', 'test2.es'), 'test2.es', "$msgprefix test1.fr -> test2.es");
+
+### beautify_urlpath
+$config{po_link_to}='default';
+$msgprefix="beautify_urlpath (po_link_to=default)";
+is(IkiWiki::beautify_urlpath('test1/index.en.html'), './test1/index.en.html', "$msgprefix test1/index.en.html");
+is(IkiWiki::beautify_urlpath('test1/index.fr.html'), './test1/index.fr.html', "$msgprefix test1/index.fr.html");
+$config{po_link_to}='negotiated';
+$msgprefix="beautify_urlpath (po_link_to=negotiated)";
+is(IkiWiki::beautify_urlpath('test1/index.html'), './test1/', "$msgprefix test1/index.html");
+is(IkiWiki::beautify_urlpath('test1/index.en.html'), './test1/', "$msgprefix test1/index.en.html");
+is(IkiWiki::beautify_urlpath('test1/index.fr.html'), './test1/', "$msgprefix test1/index.fr.html");
+$config{po_link_to}='current';
+$msgprefix="beautify_urlpath (po_link_to=current)";
+is(IkiWiki::beautify_urlpath('test1/index.en.html'), './test1/index.en.html', "$msgprefix test1/index.en.html");
+is(IkiWiki::beautify_urlpath('test1/index.fr.html'), './test1/index.fr.html', "$msgprefix test1/index.fr.html");
+
+### re-scan
+refresh_n_scan('index.mdwn');
+is($pagestate{'index'}{meta}{title}, 'index title');
+is($pagestate{'index.es'}{meta}{title}, 'index title');
+is($pagestate{'index.fr'}{meta}{title}, 'index title');
+refresh_n_scan('test1.mdwn');
+is($pagestate{'test1'}{meta}{title}, 'test1 title');
+is($pagestate{'test1.es'}{meta}{title}, 'test1 title');
+is($pagestate{'test1.fr'}{meta}{title}, 'test1 title');
+
+### istranslatedto
+ok(IkiWiki::Plugin::po::istranslatedto('index', 'es'));
+ok(IkiWiki::Plugin::po::istranslatedto('index', 'fr'));
+ok(! IkiWiki::Plugin::po::istranslatedto('index', 'cz'));
+ok(IkiWiki::Plugin::po::istranslatedto('test1', 'es'));
+ok(IkiWiki::Plugin::po::istranslatedto('test1', 'fr'));
+ok(! IkiWiki::Plugin::po::istranslatedto('test1', 'cz'));
+ok(! IkiWiki::Plugin::po::istranslatedto('nontranslatable', 'es'));
+ok(! IkiWiki::Plugin::po::istranslatedto('nontranslatable', 'cz'));
+ok(! IkiWiki::Plugin::po::istranslatedto('test1.es', 'fr'));
+ok(! IkiWiki::Plugin::po::istranslatedto('test1.fr', 'es'));
+
+### islanguagecode
+ok(IkiWiki::Plugin::po::islanguagecode('en'));
+ok(IkiWiki::Plugin::po::islanguagecode('es'));
+ok(IkiWiki::Plugin::po::islanguagecode('arn'));
+ok(! IkiWiki::Plugin::po::islanguagecode('es_'));
+ok(! IkiWiki::Plugin::po::islanguagecode('_en'));
diff --git a/t/preprocess.t b/t/preprocess.t
new file mode 100755
index 000000000..2211e8471
--- /dev/null
+++ b/t/preprocess.t
@@ -0,0 +1,83 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 34;
+
+BEGIN { use_ok("IkiWiki"); }
+
+$IkiWiki::hooks{preprocess}{foo}{call}=sub {
+ my @bits;
+ while (@_) {
+ my $key=shift;
+ my $value=shift;
+ next if $key eq 'page' || $key eq 'destpage' || $key eq 'preview';
+ if (length $value) {
+ push @bits, "$key => $value";
+ }
+ else {
+ push @bits, $key;
+ }
+ }
+ return "foo(".join(", ", @bits).")";
+};
+
+is(IkiWiki::preprocess("foo", "foo", "[[foo]]", 0, 0), "[[foo]]", "not wikilink");
+is(IkiWiki::preprocess("foo", "foo", "[[foo ]]", 0, 0), "foo()", "simple");
+is(IkiWiki::preprocess("foo", "foo", "[[!foo ]]", 0, 0), "foo()", "prefixed");
+is(IkiWiki::preprocess("foo", "foo", "[[!foo]]", 0, 0), "[[!foo]]", "prefixed, no space");
+is(IkiWiki::preprocess("foo", "foo", "[[foo a=1]]", 0, 0), "foo(a => 1)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="1"]]}, 0, 0), "foo(a => 1)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="""1"""]]}, 0, 0), "foo(a => 1)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a=""]]}, 0, 0), "foo(a)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="" b="1"]]}, 0, 0), "foo(a, b => 1)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a=""""""]]}, 0, 0), "foo(a)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="""""" b="1"]]}, 0, 0), "foo(a, b => 1)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="""""" b="""1"""]]}, 0, 0), "foo(a, b => 1)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="""""" b=""""""]]}, 0, 0), "foo(a, b)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="" b=""""""]]}, 0, 0), "foo(a, b)");
+is(IkiWiki::preprocess("foo", "foo", q{[[foo a="" b="""1"""]]}, 0, 0), "foo(a, b => 1)");
+is(IkiWiki::preprocess("foo", "foo", "[[foo a=\"1 2 3 4\"]]", 0, 0), "foo(a => 1 2 3 4)");
+is(IkiWiki::preprocess("foo", "foo", "[[foo ]] then [[foo a=2]]", 0, 0),
+ "foo() then foo(a => 2)");
+is(IkiWiki::preprocess("foo", "foo", "[[foo b c \"d and e=f\"]]", 0, 0), "foo(b, c, d and e=f)");
+is(IkiWiki::preprocess("foo", "foo", "[[foo a=1 b c=1]]", 0, 0),
+ "foo(a => 1, b, c => 1)");
+is(IkiWiki::preprocess("foo", "foo", "[[foo a=1 b c=1 \t\t]]", 0, 0),
+ "foo(a => 1, b, c => 1)", "whitespace");
+is(IkiWiki::preprocess("foo", "foo", "[[foo a=1\nb \nc=1]]", 0, 0),
+ "foo(a => 1, b, c => 1)", "multiline directive");
+is(IkiWiki::preprocess("foo", "foo", "[[foo a=1 a=2 a=3]]", 0, 0),
+ "foo(a => 1, a => 2, a => 3)", "dup item");
+is(IkiWiki::preprocess("foo", "foo", '[[foo a="[[bracketed]]" b=1]]', 0, 0),
+ "foo(a => [[bracketed]], b => 1)");
+my $multiline="here is my \"first\"
+!! [[multiline ]] !!
+string!";
+is(IkiWiki::preprocess("foo", "foo", '[[foo a="""'.$multiline.'"""]]', 0, 0),
+ "foo(a => $multiline)");
+is(IkiWiki::preprocess("foo", "foo", '[[foo """'.$multiline.'"""]]', 0, 0),
+ "foo($multiline)");
+is(IkiWiki::preprocess("foo", "foo", '[[foo a="""'.$multiline.'""" b="foo"]]', 0, 0),
+ "foo(a => $multiline, b => foo)");
+is(IkiWiki::preprocess("foo", "foo", '[[foo a="""'."\n".$multiline."\n".'""" b="foo"]]', 0, 0),
+ "foo(a => $multiline, b => foo)", "leading/trailing newline stripped");
+my $long='[[foo a="""'.("a" x 100000).'';
+is(IkiWiki::preprocess("foo", "foo", $long, 0, 0), $long,
+ "unterminated triple-quoted string inside unterminated directive(should not warn about over-recursion)");
+is(IkiWiki::preprocess("foo", "foo", $long."]]", 0, 0), $long."]]",
+ "unterminated triple-quoted string is not treated as a bare word");
+
+is(IkiWiki::preprocess("foo", "foo", "[[!foo a=<<HEREDOC\n".$multiline."\nHEREDOC]]", 0, 0),
+ "foo(a => $multiline)", "nested strings via heredoc (for key)");
+is(IkiWiki::preprocess("foo", "foo", "[[!foo <<HEREDOC\n".$multiline."\nHEREDOC]]", 0, 0),
+ "foo($multiline)", "nested strings via heredoc (without keyless)");
+is(IkiWiki::preprocess("foo", "foo", "[[!foo '''".$multiline."''']]", 0, 0),
+ "foo($multiline)", "triple-single-quoted multiline string");
+
+TODO: {
+ local $TODO = "nested strings not yet implemented";
+
+ $multiline='here is a string containing another [[foo val="""string""]]';
+ is(IkiWiki::preprocess("foo", "foo", '[[foo a="""'.$multiline.'"""]]', 0, 0),
+ "foo(a => $multiline)", "nested multiline strings");
+}
diff --git a/t/prune.t b/t/prune.t
new file mode 100755
index 000000000..8c3925e9e
--- /dev/null
+++ b/t/prune.t
@@ -0,0 +1,23 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 6;
+use File::Path qw(make_path remove_tree);
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Render"); }
+
+%config=IkiWiki::defaultconfig();
+
+remove_tree("t/tmp");
+
+make_path("t/tmp/srcdir/a/b/c");
+make_path("t/tmp/srcdir/d/e/f");
+writefile("a/b/c/d.mdwn", "t/tmp/srcdir", "foo");
+writefile("d/e/f/g.mdwn", "t/tmp/srcdir", "foo");
+IkiWiki::prune("t/tmp/srcdir/d/e/f/g.mdwn");
+ok(-d "t/tmp/srcdir");
+ok(! -e "t/tmp/srcdir/d");
+IkiWiki::prune("t/tmp/srcdir/a/b/c/d.mdwn", "t/tmp/srcdir");
+ok(-d "t/tmp/srcdir");
+ok(! -e "t/tmp/srcdir/a");
diff --git a/t/readfile.t b/t/readfile.t
new file mode 100755
index 000000000..bb1c6bae3
--- /dev/null
+++ b/t/readfile.t
@@ -0,0 +1,12 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 3;
+use Encode;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# should read files as utf8
+ok(Encode::is_utf8(readfile("t/test1.mdwn"), 1));
+is(readfile("t/test1.mdwn"),
+ Encode::decode_utf8('![o](../images/o.jpg "ó")'."\n".'óóóóó'."\n"));
diff --git a/t/renamepage.t b/t/renamepage.t
new file mode 100755
index 000000000..a706cbb46
--- /dev/null
+++ b/t/renamepage.t
@@ -0,0 +1,51 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 21;
+use Encode;
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Plugin::link"); }
+
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+IkiWiki::checkconfig();
+
+# tests of the link plugin's renamepage function
+sub try {
+ my ($page, $oldpage, $newpage, $content)=@_;
+
+ %IkiWiki::pagecase=();
+ %links=();
+ $IkiWiki::config{userdir}="foouserdir";
+ foreach my $page ($page, $oldpage, $newpage) {
+ $IkiWiki::pagecase{lc $page}=$page;
+ $links{$page}=[];
+ }
+
+ IkiWiki::Plugin::link::renamepage(
+ page => $page,
+ oldpage => $oldpage,
+ newpage => $newpage,
+ content => $content,
+ );
+}
+is(try("z", "foo" => "bar", "[[xxx]]"), "[[xxx]]"); # unrelated link
+is(try("z", "foo" => "bar", "[[bar]]"), "[[bar]]"); # link already to new page
+is(try("z", "foo" => "bar", "[[foo]]"), "[[bar]]"); # basic conversion to new page name
+is(try("z", "foo" => "bar", "[[/foo]]"), "[[/bar]]"); # absolute link
+is(try("z", "foo" => "bar", "[[Foo]]"), "[[Bar]]"); # preserve case
+is(try("z", "x/foo" => "x/bar", "[[x/Foo]]"), "[[x/Bar]]"); # preserve case of subpage
+is(try("z", "foo" => "bar", "[[/Foo]]"), "[[/Bar]]"); # preserve case w/absolute
+is(try("z", "foo" => "bar", "[[foo]] [[xxx]]"), "[[bar]] [[xxx]]"); # 2 links, 1 converted
+is(try("z", "foo" => "bar", "[[xxx|foo]]"), "[[xxx|bar]]"); # conversion w/text
+is(try("z", "foo" => "bar", "[[foo#anchor]]"), "[[bar#anchor]]"); # with anchor
+is(try("z", "foo" => "bar", "[[xxx|foo#anchor]]"), "[[xxx|bar#anchor]]"); # with anchor
+is(try("z", "foo" => "bar", "[[!moo ]]"), "[[!moo ]]"); # preprocessor directive unchanged
+is(try("bugs", "bugs/foo" => "wishlist/bar", "[[foo]]"), "[[wishlist/bar]]"); # subpage link
+is(try("z", "foo_bar" => "bar", "[[foo_bar]]"), "[[bar]]"); # old link with underscore
+is(try("z", "foo" => "bar_foo", "[[foo]]"), "[[bar_foo]]"); # new link with underscore
+is(try("z", "foo_bar" => "bar_foo", "[[foo_bar]]"), "[[bar_foo]]"); # both with underscore
+is(try("z", "foo" => "bar__".ord("(")."__", "[[foo]]"), "[[bar(]]"); # new link with escaped chars
+is(try("z", "foo__".ord("(")."__" => "bar(", "[[foo(]]"), "[[bar(]]"); # old link with escaped chars
+is(try("z", "foo__".ord("(")."__" => "bar__".ord(")")."__", "[[foo(]]"), "[[bar)]]"); # both with escaped chars
diff --git a/t/rssurls.t b/t/rssurls.t
new file mode 100755
index 000000000..870770496
--- /dev/null
+++ b/t/rssurls.t
@@ -0,0 +1,37 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 13;
+
+BEGIN { use_ok("IkiWiki::Plugin::inline"); }
+
+# Test the absolute_urls function, used to fix up relative urls for rss
+# feeds.
+sub test {
+ my $input=shift;
+ my $baseurl=shift;
+ my $expected=shift;
+ $expected=~s/URL/$baseurl/g;
+ is(IkiWiki::absolute_urls($input, $baseurl), $expected);
+ # try it with single quoting -- it's ok if the result comes back
+ # double or single-quoted
+ $input=~s/"/'/g;
+ my $expected_alt=$expected;
+ $expected_alt=~s/"/'/g;
+ my $ret=IkiWiki::absolute_urls($input, $baseurl);
+ ok(($ret eq $expected) || ($ret eq $expected_alt), "$ret vs $expected");
+}
+
+sub unchanged {
+ test($_[0], $_[1], $_[0]);
+}
+
+my $url="http://example.com/blog/foo/";
+unchanged("foo", $url);
+unchanged('<a href="http://other.com/bar.html">', $url, );
+test('<a href="bar.html">', $url, '<a href="URLbar.html">');
+test('<a href="/bar.html">', $url, '<a href="http://example.com/bar.html">');
+test('<img src="bar.png" />', $url, '<img src="URLbar.png" />');
+test('<img src="/bar.png" />', $url, '<img src="http://example.com/bar.png" />');
+# off until bug #603736 is fixed
+#test('<video controls src="bar.ogg">', $url, '<video controls src="URLbar.ogg">');
diff --git a/t/rst.t b/t/rst.t
new file mode 100755
index 000000000..4e0c4b747
--- /dev/null
+++ b/t/rst.t
@@ -0,0 +1,22 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+BEGIN {
+ if (system("python -c 'import docutils.core'") != 0) {
+ eval 'use Test::More skip_all => "docutils not available"';
+ }
+}
+
+use Test::More tests => 2;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{srcdir}=$config{destdir}="/dev/null";
+$config{libdir}=".";
+$config{add_plugins}=[qw(rst)];
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+ok(IkiWiki::htmlize("foo", "foo", "rst", "foo\n") =~ m{\s*<p>foo</p>\s*});
diff --git a/t/svn.t b/t/svn.t
new file mode 100755
index 000000000..cce8452a6
--- /dev/null
+++ b/t/svn.t
@@ -0,0 +1,78 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+my $dir;
+BEGIN {
+ $dir="/tmp/ikiwiki-test-svn.$$";
+ my $svn=`which svn`;
+ chomp $svn;
+ my $svnadmin=`which svnadmin`;
+ chomp $svnadmin;
+ if (! -x $svn || ! -x $svnadmin) {
+ eval q{
+ use Test::More skip_all => "svn or svnadmin not available"
+ }
+ }
+ if (! mkdir($dir)) {
+ die $@;
+ }
+}
+use Test::More tests => 12;
+
+BEGIN { use_ok("IkiWiki"); }
+
+%config=IkiWiki::defaultconfig();
+$config{rcs} = "svn";
+$config{srcdir} = "$dir/src";
+$config{svnrepo} = "$dir/repo";
+$config{svnpath} = "trunk";
+IkiWiki::loadplugins();
+IkiWiki::checkconfig();
+
+my $svnrepo = "$dir/repo";
+
+system "svnadmin create $svnrepo >/dev/null";
+system "svn mkdir file://$svnrepo/trunk -m add >/dev/null";
+system "svn co file://$svnrepo/trunk $config{srcdir} >/dev/null";
+
+# Web commit
+my $test1 = readfile("t/test1.mdwn");
+writefile('test1.mdwn', $config{srcdir}, $test1);
+IkiWiki::rcs_add("test1.mdwn");
+IkiWiki::rcs_commit(
+ file => "test1.mdwn",
+ message => "Added the first page",
+ token => "moo",
+);
+
+my @changes;
+@changes = IkiWiki::rcs_recentchanges(3);
+
+is($#changes, 0);
+is($changes[0]{message}[0]{"line"}, "Added the first page");
+is($changes[0]{pages}[0]{"page"}, "test1");
+
+# Manual commit
+my $message = "Added the second page";
+
+my $test2 = readfile("t/test2.mdwn");
+writefile('test2.mdwn', $config{srcdir}, $test2);
+system "svn add $config{srcdir}/test2.mdwn >/dev/null";
+system "svn commit $config{srcdir}/test2.mdwn -m \"$message\" >/dev/null";
+
+@changes = IkiWiki::rcs_recentchanges(3);
+is($#changes, 1);
+is($changes[0]{message}[0]{"line"}, $message);
+is($changes[0]{pages}[0]{"page"}, "test2");
+is($changes[1]{pages}[0]{"page"}, "test1");
+
+# extra slashes in the path shouldn't break things
+$config{svnpath} = "/trunk//";
+IkiWiki::checkconfig();
+@changes = IkiWiki::rcs_recentchanges(3);
+is($#changes, 1);
+is($changes[0]{message}[0]{"line"}, $message);
+is($changes[0]{pages}[0]{"page"}, "test2");
+is($changes[1]{pages}[0]{"page"}, "test1");
+
+system "rm -rf $dir";
diff --git a/t/syntax.t b/t/syntax.t
new file mode 100755
index 000000000..b7c6efd58
--- /dev/null
+++ b/t/syntax.t
@@ -0,0 +1,20 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More;
+
+my @progs="ikiwiki.in";
+my @libs="IkiWiki.pm";
+# monotone, external, amazon_s3, po, and cvs
+# skipped since they need perl modules
+push @libs, map { chomp; $_ } `find IkiWiki -type f -name \\*.pm | grep -v monotone.pm | grep -v external.pm | grep -v amazon_s3.pm | grep -v po.pm | grep -v cvs.pm`;
+push @libs, 'IkiWiki/Plugin/skeleton.pm.example';
+
+plan(tests => (@progs + @libs));
+
+foreach my $file (@progs) {
+ ok(system("perl -c $file >/dev/null 2>&1") eq 0, $file);
+}
+foreach my $file (@libs) {
+ ok(system("perl -c $file >/dev/null 2>&1") eq 0, $file);
+}
diff --git a/t/tag.t b/t/tag.t
new file mode 100755
index 000000000..cc0a30cad
--- /dev/null
+++ b/t/tag.t
@@ -0,0 +1,88 @@
+#!/usr/bin/perl
+package IkiWiki;
+
+use warnings;
+use strict;
+use Test::More tests => 24;
+
+BEGIN { use_ok("IkiWiki"); }
+BEGIN { use_ok("IkiWiki::Render"); }
+BEGIN { use_ok("IkiWiki::Plugin::mdwn"); }
+BEGIN { use_ok("IkiWiki::Plugin::tag"); }
+
+ok(! system("rm -rf t/tmp; mkdir t/tmp"));
+
+$config{srcdir} = 't/tmp';
+$config{underlaydir} = 't/tmp';
+$config{templatedir} = 'templates';
+$config{usedirs} = 1;
+$config{htmlext} = 'html';
+$config{wiki_file_chars} = "-[:alnum:]+/.:_";
+$config{userdir} = "users";
+$config{tagbase} = "tags";
+$config{tag_autocreate} = 1;
+$config{tag_autocreate_commit} = 0;
+$config{default_pageext} = "mdwn";
+$config{wiki_file_prune_regexps} = [qr/^\./];
+$config{underlaydirbase} = '.';
+
+is(checkconfig(), 1);
+
+%oldrenderedfiles=%pagectime=();
+%pagesources=%pagemtime=%oldlinks=%links=%depends=%typedlinks=%oldtypedlinks=
+%destsources=%renderedfiles=%pagecase=%pagestate=();
+
+foreach my $page (qw(tags/numbers tags/letters one two alpha beta)) {
+ $pagesources{$page} = "$page.mdwn";
+ $pagemtime{$page} = $pagectime{$page} = 1000000;
+ writefile("$page.mdwn", "t/tmp", "your ad here");
+}
+
+$links{one}=[qw(tags/numbers alpha tags/letters)];
+$links{two}=[qw(tags/numbers)];
+$links{alpha}=[qw(tags/letters one)];
+$links{beta}=[qw(tags/letters)];
+$typedlinks{one}={tag => {"tags/numbers" => 1 }};
+$typedlinks{two}={tag => {"tags/numbers" => 1 }};
+$typedlinks{alpha}={tag => {"tags/letters" => 1 }};
+$typedlinks{beta}={tag => {"tags/letters" => 1 }};
+
+ok(pagespec_match("one", "tagged(numbers)"));
+ok(!pagespec_match("two", "tagged(alpha)"));
+ok(pagespec_match("one", "link(tags/numbers)"));
+ok(pagespec_match("one", "link(alpha)"));
+
+# emulate preprocessing [[!tag numbers primes lucky]] on page "seven", causing
+# the "numbers" and "primes" tag pages to be auto-created
+IkiWiki::Plugin::tag::preprocess_tag(page => "seven", numbers => undef, primes => undef, lucky => undef);
+is($autofiles{"tags/lucky.mdwn"}{plugin}, "tag");
+is($autofiles{"tags/numbers.mdwn"}{plugin}, "tag");
+is($autofiles{"tags/primes.mdwn"}{plugin}, "tag");
+is_deeply([sort keys %autofiles], [qw(tags/lucky.mdwn tags/numbers.mdwn tags/primes.mdwn)]);
+
+ok(!-e "t/tmp/tags/lucky.mdwn");
+my (%pages, @del);
+IkiWiki::gen_autofile("tags/lucky.mdwn", \%pages, \@del);
+ok(! -s "t/tmp/tags/lucky.mdwn");
+ok(-s "t/tmp/.ikiwiki/transient/tags/lucky.mdwn");
+is_deeply(\%pages, {"t/tmp/tags/lucky" => 1});
+is_deeply(\@del, []);
+
+# generating an autofile that already exists does nothing
+%pages = @del = ();
+IkiWiki::gen_autofile("tags/numbers.mdwn", \%pages, \@del);
+is_deeply(\%pages, {});
+is_deeply(\@del, []);
+
+# generating an autofile that we just deleted does nothing
+%pages = ();
+@del = ('tags/primes.mdwn');
+IkiWiki::gen_autofile("tags/primes.mdwn", \%pages, \@del);
+is_deeply(\%pages, {});
+is_deeply(\@del, ['tags/primes.mdwn']);
+
+
+# cleanup
+ok(! system("rm -rf t/tmp"));
+
+1;
diff --git a/t/template_syntax.t b/t/template_syntax.t
new file mode 100755
index 000000000..1e156eed8
--- /dev/null
+++ b/t/template_syntax.t
@@ -0,0 +1,15 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More;
+
+my @templates=glob("templates/*.tmpl"), glob("doc/templates/*.mdwn");
+plan(tests => 2*@templates);
+
+use HTML::Template;
+
+foreach my $template (@templates) {
+ my $obj=eval {HTML::Template->new(filename => $template)};
+ ok(! $@, $template." $@");
+ ok($obj, $template);
+}
diff --git a/t/templates_documented.t b/t/templates_documented.t
new file mode 100755
index 000000000..826c51d36
--- /dev/null
+++ b/t/templates_documented.t
@@ -0,0 +1,14 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More 'no_plan';
+
+$/=undef;
+open(IN, "doc/templates.mdwn") || die "doc/templates.mdwn: $!";
+my $page=<IN>;
+close IN;
+
+foreach my $file (glob("templates/*.tmpl")) {
+ $file=~s/templates\///;
+ ok($page =~ /\Q$file\E/, "$file documented on doc/templates.mdwn");
+}
diff --git a/t/test1.mdwn b/t/test1.mdwn
new file mode 100644
index 000000000..f4ebc2c08
--- /dev/null
+++ b/t/test1.mdwn
@@ -0,0 +1,2 @@
+![o](../images/o.jpg "ó")
+óóóóó
diff --git a/t/test2.mdwn b/t/test2.mdwn
new file mode 100644
index 000000000..7e9b15f80
--- /dev/null
+++ b/t/test2.mdwn
@@ -0,0 +1,5 @@
+<form>
+</form>
+<ul>
+<li>ş <--
+</ul>
diff --git a/t/test3.mdwn b/t/test3.mdwn
new file mode 100644
index 000000000..541628bb4
--- /dev/null
+++ b/t/test3.mdwn
@@ -0,0 +1 @@
+<h1>☺</h1>
diff --git a/t/tinyblog/index.mdwn b/t/tinyblog/index.mdwn
new file mode 100644
index 000000000..72ba7846a
--- /dev/null
+++ b/t/tinyblog/index.mdwn
@@ -0,0 +1 @@
+[[!inline pages="post" rss=yes]]
diff --git a/t/tinyblog/post.mdwn b/t/tinyblog/post.mdwn
new file mode 100644
index 000000000..9eaeec7ef
--- /dev/null
+++ b/t/tinyblog/post.mdwn
@@ -0,0 +1 @@
+only post
diff --git a/t/titlepage.t b/t/titlepage.t
new file mode 100755
index 000000000..5df33423e
--- /dev/null
+++ b/t/titlepage.t
@@ -0,0 +1,13 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 7;
+
+BEGIN { use_ok("IkiWiki"); }
+
+is(titlepage("foo bar"), "foo_bar");
+is(titlepage("foo bar baz"), "foo_bar_baz");
+is(titlepage("foo bar/baz"), "foo_bar/baz");
+is(titlepage("foo bar&baz"), "foo_bar__38__baz");
+is(titlepage("foo bar & baz"), "foo_bar___38___baz");
+is(titlepage("foo bar_baz"), "foo_bar__95__baz");
diff --git a/t/trail.t b/t/trail.t
new file mode 100755
index 000000000..dce3b3c7e
--- /dev/null
+++ b/t/trail.t
@@ -0,0 +1,292 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More 'no_plan';
+use IkiWiki;
+
+sub check_trail {
+ my $file=shift;
+ my $expected=shift;
+ my $trailname=shift || qr/\w+/;
+ my $blob=readfile("t/tmp/out/$file");
+ my ($trailline)=$blob=~/^trail=$trailname\s+(.*)$/m;
+ is($trailline, $expected, "expected $expected in $file");
+}
+
+sub check_no_trail {
+ my $file=shift;
+ my $trailname=shift || qr/\w+/;
+ my $blob=readfile("t/tmp/out/$file");
+ my ($trailline)=$blob=~/^trail=$trailname\s+(.*)$/m;
+ $trailline="" unless defined $trailline;
+ ok($trailline !~ /^trail=$trailname\s+/, "no trail $trailname in $file");
+}
+
+my $blob;
+
+ok(! system("rm -rf t/tmp"));
+ok(! system("mkdir t/tmp"));
+
+# Write files with a date in the past, so that when we refresh,
+# the update is detected.
+sub write_old_file {
+ my $name = shift;
+ my $content = shift;
+
+ writefile($name, "t/tmp/in", $content);
+ ok(utime(333333333, 333333333, "t/tmp/in/$name"));
+}
+
+# Use a rather stylized template to override the default rendering, to make
+# it easy to search for the desired results
+write_old_file("templates/trails.tmpl", <<EOF
+<TMPL_LOOP TRAILLOOP>
+<TMPL_IF __FIRST__><nav></TMPL_IF>
+<div>
+trail=<TMPL_VAR TRAILPAGE> n=<TMPL_VAR NEXTPAGE> p=<TMPL_VAR PREVPAGE>
+</div>
+<div>
+<TMPL_IF PREVURL>
+<a href="<TMPL_VAR PREVURL>">&lt; <TMPL_VAR PREVTITLE></a>
+</TMPL_IF> |
+<a href="<TMPL_VAR TRAILURL>">^ <TMPL_VAR TRAILTITLE> ^</a>
+| <TMPL_IF NEXTURL>
+<a href="<TMPL_VAR NEXTURL>"><TMPL_VAR NEXTTITLE> &gt;</a>
+</TMPL_IF>
+</div>
+<TMPL_IF __LAST__></nav></TMPL_IF>
+</TMPL_LOOP>
+EOF
+);
+write_old_file("badger.mdwn", "[[!meta title=\"The Breezy Badger\"]]\ncontent of badger");
+write_old_file("mushroom.mdwn", "content of mushroom");
+write_old_file("snake.mdwn", "content of snake");
+write_old_file("ratty.mdwn", "content of ratty");
+write_old_file("mr_toad.mdwn", "content of mr toad");
+write_old_file("add.mdwn", '[[!trailitems pagenames="add/a add/b add/c add/d add/e"]]');
+write_old_file("add/b.mdwn", "b");
+write_old_file("add/d.mdwn", "d");
+write_old_file("del.mdwn", '[[!trailitems pages="del/*" sort=title]]');
+write_old_file("del/a.mdwn", "a");
+write_old_file("del/b.mdwn", "b");
+write_old_file("del/c.mdwn", "c");
+write_old_file("del/d.mdwn", "d");
+write_old_file("del/e.mdwn", "e");
+write_old_file("self_referential.mdwn", '[[!trailitems pagenames="self_referential" circular=yes]]');
+write_old_file("sorting/linked.mdwn", "linked");
+write_old_file("sorting/a/b.mdwn", "a/b");
+write_old_file("sorting/a/c.mdwn", "a/c");
+write_old_file("sorting/z/a.mdwn", "z/a");
+write_old_file("sorting/beginning.mdwn", "beginning");
+write_old_file("sorting/middle.mdwn", "middle");
+write_old_file("sorting/end.mdwn", "end");
+write_old_file("sorting/new.mdwn", "new");
+write_old_file("sorting/old.mdwn", "old");
+write_old_file("sorting/ancient.mdwn", "ancient");
+# These three need to be in the appropriate age order
+ok(utime(333333333, 333333333, "t/tmp/in/sorting/new.mdwn"));
+ok(utime(222222222, 222222222, "t/tmp/in/sorting/old.mdwn"));
+ok(utime(111111111, 111111111, "t/tmp/in/sorting/ancient.mdwn"));
+write_old_file("sorting/linked2.mdwn", "linked2");
+# This initially uses the default sort order: age for the inline, and path
+# for trailitems. We change it later.
+write_old_file("sorting.mdwn",
+ '[[!traillink linked]] ' .
+ '[[!trailitems pages="sorting/z/a or sorting/a/b or sorting/a/c"]] ' .
+ '[[!trailitems pagenames="sorting/beginning sorting/middle sorting/end"]] ' .
+ '[[!inline pages="sorting/old or sorting/ancient or sorting/new" trail="yes"]] ' .
+ '[[!traillink linked2]]');
+write_old_file("limited/a.mdwn", "a");
+write_old_file("limited/b.mdwn", "b");
+write_old_file("limited/c.mdwn", "c");
+write_old_file("limited/d.mdwn", "d");
+write_old_file("limited.mdwn",
+ '[[!inline pages="limited/*" trail="yes" show=2 sort=title]]');
+write_old_file("untrail/a.mdwn", "a");
+write_old_file("untrail/b.mdwn", "b");
+write_old_file("untrail.mdwn", "[[!traillink a]] [[!traillink b]]");
+write_old_file("retitled/a.mdwn", "a");
+write_old_file("retitled.mdwn",
+ '[[!meta title="the old title"]][[!traillink a]]');
+
+write_old_file("meme.mdwn", <<EOF
+[[!trail]]
+* [[!traillink badger]]
+* [[!traillink badger text="This is a link to badger, with a title"]]
+* [[!traillink That_is_the_badger|badger]]
+* [[!traillink badger]]
+* [[!traillink mushroom]]
+* [[!traillink mushroom]]
+* [[!traillink snake]]
+* [[!traillink snake]]
+EOF
+);
+
+write_old_file("wind_in_the_willows.mdwn", <<EOF
+[[!trailoptions circular=yes sort=title]]
+[[!trailitems pages="ratty or badger or mr_toad"]]
+[[!trailitem moley]]
+EOF
+);
+
+ok(! system("make -s ikiwiki.out"));
+
+my $command = "perl -I. ./ikiwiki.out -set usedirs=0 -plugin trail -plugin inline -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates t/tmp/in t/tmp/out -verbose";
+
+ok(! system($command));
+
+ok(! system("$command -refresh"));
+
+$blob = readfile("t/tmp/out/meme.html");
+ok($blob =~ /<a href="(\.\/)?badger.html">badger<\/a>/m);
+ok($blob =~ /<a href="(\.\/)?badger.html">This is a link to badger, with a title<\/a>/m);
+ok($blob =~ /<a href="(\.\/)?badger.html">That is the badger<\/a>/m);
+
+check_trail("badger.html", "n=mushroom p=", "meme");
+check_trail("badger.html", "n=mr_toad p=ratty", "wind_in_the_willows");
+
+ok(! -f "t/tmp/out/moley.html");
+
+check_trail("mr_toad.html", "n=ratty p=badger", "wind_in_the_willows");
+check_no_trail("mr_toad.html", "meme");
+# meta title is respected for pages that have one
+$blob = readfile("t/tmp/out/mr_toad.html");
+ok($blob =~ /">&lt; The Breezy Badger<\/a>/m);
+# pagetitle for pages that don't
+ok($blob =~ /">ratty &gt;<\/a>/m);
+
+check_no_trail("ratty.html", "meme");
+check_trail("ratty.html", "n=badger p=mr_toad", "wind_in_the_willows");
+
+check_trail("mushroom.html", "n=snake p=badger", "meme");
+check_no_trail("mushroom.html", "wind_in_the_willows");
+
+check_trail("snake.html", "n= p=mushroom", "meme");
+check_no_trail("snake.html", "wind_in_the_willows");
+
+check_trail("self_referential.html", "n= p=", "self_referential");
+
+check_trail("add/b.html", "n=add/d p=", "add");
+check_trail("add/d.html", "n= p=add/b", "add");
+ok(! -f "t/tmp/out/add/a.html");
+ok(! -f "t/tmp/out/add/c.html");
+ok(! -f "t/tmp/out/add/e.html");
+
+check_trail("del/a.html", "n=del/b p=");
+check_trail("del/b.html", "n=del/c p=del/a");
+check_trail("del/c.html", "n=del/d p=del/b");
+check_trail("del/d.html", "n=del/e p=del/c");
+check_trail("del/e.html", "n= p=del/d");
+
+check_trail("sorting/linked.html", "n=sorting/a/b p=");
+check_trail("sorting/a/b.html", "n=sorting/a/c p=sorting/linked");
+check_trail("sorting/a/c.html", "n=sorting/z/a p=sorting/a/b");
+check_trail("sorting/z/a.html", "n=sorting/beginning p=sorting/a/c");
+check_trail("sorting/beginning.html", "n=sorting/middle p=sorting/z/a");
+check_trail("sorting/middle.html", "n=sorting/end p=sorting/beginning");
+check_trail("sorting/end.html", "n=sorting/new p=sorting/middle");
+check_trail("sorting/new.html", "n=sorting/old p=sorting/end");
+check_trail("sorting/old.html", "n=sorting/ancient p=sorting/new");
+check_trail("sorting/ancient.html", "n=sorting/linked2 p=sorting/old");
+check_trail("sorting/linked2.html", "n= p=sorting/ancient");
+
+# If the inline has a limited number of pages, the trail still contains
+# everything.
+$blob = readfile("t/tmp/out/limited.html");
+ok($blob =~ /<a href="(\.\/)?limited\/a.html">a<\/a>/m);
+ok($blob =~ /<a href="(\.\/)?limited\/b.html">b<\/a>/m);
+ok($blob !~ /<a href="(\.\/)?limited\/c.html">/m);
+ok($blob !~ /<a href="(\.\/)?limited\/d.html">/m);
+check_trail("limited/a.html", "n=limited/b p=");
+check_trail("limited/b.html", "n=limited/c p=limited/a");
+check_trail("limited/c.html", "n=limited/d p=limited/b");
+check_trail("limited/d.html", "n= p=limited/c");
+
+check_trail("untrail/a.html", "n=untrail/b p=");
+check_trail("untrail/b.html", "n= p=untrail/a");
+
+$blob = readfile("t/tmp/out/retitled/a.html");
+ok($blob =~ /\^ the old title \^/m);
+
+# Make some changes and refresh. These writefile calls don't set an
+# old mtime, so they're strictly newer than the "old" files.
+
+writefile("add/a.mdwn", "t/tmp/in", "a");
+writefile("add/c.mdwn", "t/tmp/in", "c");
+writefile("add/e.mdwn", "t/tmp/in", "e");
+ok(unlink("t/tmp/in/del/a.mdwn"));
+ok(unlink("t/tmp/in/del/c.mdwn"));
+ok(unlink("t/tmp/in/del/e.mdwn"));
+
+writefile("sorting.mdwn", "t/tmp/in",
+ readfile("t/tmp/in/sorting.mdwn") .
+ '[[!trailoptions sort="title" reverse="yes"]]');
+
+writefile("retitled.mdwn", "t/tmp/in",
+ '[[!meta title="the new title"]][[!traillink a]]');
+
+# If the inline has a limited number of pages, the trail still depends on
+# everything.
+writefile("limited.html", "t/tmp/out", "[this gets rebuilt]");
+writefile("limited/c.mdwn", "t/tmp/in", '[[!meta title="New C page"]]c');
+
+writefile("untrail.mdwn", "t/tmp/in", "no longer a trail");
+
+ok(! system("$command -refresh"));
+
+check_trail("add/a.html", "n=add/b p=");
+check_trail("add/b.html", "n=add/c p=add/a");
+check_trail("add/c.html", "n=add/d p=add/b");
+check_trail("add/d.html", "n=add/e p=add/c");
+check_trail("add/e.html", "n= p=add/d");
+
+check_trail("del/b.html", "n=del/d p=");
+check_trail("del/d.html", "n= p=del/b");
+ok(! -f "t/tmp/out/del/a.html");
+ok(! -f "t/tmp/out/del/c.html");
+ok(! -f "t/tmp/out/del/e.html");
+
+check_trail("sorting/old.html", "n=sorting/new p=");
+check_trail("sorting/new.html", "n=sorting/middle p=sorting/old");
+check_trail("sorting/middle.html", "n=sorting/linked2 p=sorting/new");
+check_trail("sorting/linked2.html", "n=sorting/linked p=sorting/middle");
+check_trail("sorting/linked.html", "n=sorting/end p=sorting/linked2");
+check_trail("sorting/end.html", "n=sorting/a/c p=sorting/linked");
+check_trail("sorting/a/c.html", "n=sorting/beginning p=sorting/end");
+check_trail("sorting/beginning.html", "n=sorting/a/b p=sorting/a/c");
+check_trail("sorting/a/b.html", "n=sorting/ancient p=sorting/beginning");
+check_trail("sorting/ancient.html", "n=sorting/z/a p=sorting/a/b");
+check_trail("sorting/z/a.html", "n= p=sorting/ancient");
+
+# If the inline has a limited number of pages, the trail still depends on
+# everything, so it gets rebuilt even though it doesn't strictly need it.
+# This means we could use it as a way to recompute the order of members
+# and the contents of their trail navbars, allowing us to fix the regression
+# described in [[bugs/trail excess dependencies]] without a full content
+# dependency.
+$blob = readfile("t/tmp/out/limited.html");
+ok($blob =~ /<a href="(\.\/)?limited\/a.html">a<\/a>/m);
+ok($blob =~ /<a href="(\.\/)?limited\/b.html">b<\/a>/m);
+ok($blob !~ /<a href="(\.\/)?limited\/c.html">/m);
+ok($blob !~ /<a href="(\.\/)?limited\/d.html">/m);
+check_trail("limited/a.html", "n=limited/b p=");
+check_trail("limited/b.html", "n=limited/c p=limited/a");
+check_trail("limited/c.html", "n=limited/d p=limited/b");
+check_trail("limited/d.html", "n= p=limited/c");
+# Also, b and d should pick up the change to c. This regressed with the
+# change to using a presence dependency.
+$blob = readfile("t/tmp/out/limited/b.html");
+ok($blob =~ /New C page &gt;/m);
+$blob = readfile("t/tmp/out/limited/d.html");
+ok($blob =~ /&lt; New C page/m);
+
+# Members of a retitled trail should pick up that change.
+# This regressed with the change to using a presence dependency.
+$blob = readfile("t/tmp/out/retitled/a.html");
+ok($blob =~ /\^ the new title \^/m);
+
+# untrail is no longer a trail, so these are no longer in it.
+check_no_trail("untrail/a.html");
+check_no_trail("untrail/b.html");
+
+ok(! system("rm -rf t/tmp"));
diff --git a/t/urlto.t b/t/urlto.t
new file mode 100755
index 000000000..338632e94
--- /dev/null
+++ b/t/urlto.t
@@ -0,0 +1,51 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 26;
+
+BEGIN { use_ok("IkiWiki"); }
+
+$IkiWiki::config{srcdir} = '/does/not/exist/';
+$IkiWiki::config{usedirs} = 1;
+$IkiWiki::config{htmlext} = "HTML";
+$IkiWiki::config{wiki_file_chars} = "A-Za-z0-9._";
+
+$IkiWiki::config{url} = "http://smcv.example.co.uk";
+$IkiWiki::config{cgiurl} = "http://smcv.example.co.uk/cgi-bin/ikiwiki.cgi";
+is(IkiWiki::checkconfig(), 1);
+
+# absolute version
+is(IkiWiki::cgiurl(cgiurl => $config{cgiurl}), "http://smcv.example.co.uk/cgi-bin/ikiwiki.cgi");
+is(IkiWiki::cgiurl(cgiurl => $config{cgiurl}, do => 'badger'), "http://smcv.example.co.uk/cgi-bin/ikiwiki.cgi?do=badger");
+is(IkiWiki::urlto('index', undef, 1), "http://smcv.example.co.uk/");
+is(IkiWiki::urlto('stoats', undef, 1), "http://smcv.example.co.uk/stoats/");
+is(IkiWiki::urlto('', undef, 1), "http://smcv.example.co.uk/");
+
+# "local" (absolute path within site) version (default for cgiurl)
+is(IkiWiki::cgiurl(), "/cgi-bin/ikiwiki.cgi");
+is(IkiWiki::cgiurl(do => 'badger'), "/cgi-bin/ikiwiki.cgi?do=badger");
+is(IkiWiki::baseurl(undef), "/");
+is(IkiWiki::urlto('index', undef), "/");
+is(IkiWiki::urlto('index'), "/");
+is(IkiWiki::urlto('stoats', undef), "/stoats/");
+is(IkiWiki::urlto('stoats'), "/stoats/");
+is(IkiWiki::urlto(''), "/");
+
+# fully-relative version (default for urlto and baseurl)
+is(IkiWiki::baseurl('badger/mushroom'), "../../");
+is(IkiWiki::urlto('badger/mushroom', 'snake'), "../badger/mushroom/");
+is(IkiWiki::urlto('', 'snake'), "../");
+is(IkiWiki::urlto('', 'penguin/herring'), "../../");
+
+# explicit cgiurl override
+is(IkiWiki::cgiurl(cgiurl => 'https://foo/ikiwiki'), "https://foo/ikiwiki");
+is(IkiWiki::cgiurl(do => 'badger', cgiurl => 'https://foo/ikiwiki'), "https://foo/ikiwiki?do=badger");
+
+# with url and cgiurl on different sites, "local" degrades to absolute
+$IkiWiki::config{url} = "http://example.co.uk/~smcv";
+$IkiWiki::config{cgiurl} = "http://dynamic.example.co.uk/~smcv/ikiwiki.cgi";
+is(IkiWiki::checkconfig(), 1);
+is(IkiWiki::cgiurl(), "http://dynamic.example.co.uk/~smcv/ikiwiki.cgi");
+is(IkiWiki::baseurl(undef), "http://example.co.uk/~smcv/");
+is(IkiWiki::urlto('stoats', undef), "http://example.co.uk/~smcv/stoats/");
+is(IkiWiki::urlto('', undef), "http://example.co.uk/~smcv/");
diff --git a/t/yesno.t b/t/yesno.t
new file mode 100755
index 000000000..8770390a1
--- /dev/null
+++ b/t/yesno.t
@@ -0,0 +1,23 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+use Test::More tests => 11;
+
+BEGIN { use_ok("IkiWiki"); }
+
+# note: yesno always accepts English even if localized.
+# So no need to bother setting locale to C.
+
+ok(IkiWiki::yesno("yes") == 1);
+ok(IkiWiki::yesno("Yes") == 1);
+ok(IkiWiki::yesno("YES") == 1);
+
+ok(IkiWiki::yesno("no") == 0);
+ok(IkiWiki::yesno("No") == 0);
+ok(IkiWiki::yesno("NO") == 0);
+
+ok(IkiWiki::yesno("1") == 1);
+ok(IkiWiki::yesno("0") == 0);
+ok(IkiWiki::yesno("mooooooooooo") == 0);
+
+ok(IkiWiki::yesno(undef) == 0);