changeset 76:e37662876b51

Update hatta.py and add documentation and license.
author Radomir Dopieralski <sheep-devel@sheep.art.pl>
date Sun, 24 Feb 2013 16:54:54 +0100
parents d44dd192eb12
children e2865a7e11aa
files LICENSE.txt README.txt hatta.py requirements.txt
diffstat 4 files changed, 320 insertions(+), 287 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE.txt	Sun Feb 24 16:54:54 2013 +0100
@@ -0,0 +1,1 @@
+This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/3.0/.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.txt	Sun Feb 24 16:54:54 2013 +0100
@@ -0,0 +1,45 @@
+What is this?
+=============
+
+QQW is an experiment at merging an adventure video game with a wiki website.
+The players can walk around the maps and interact with them like in a game,
+but at the same time they can edit the maps and create new ones, extending the
+game's world and adding their own pieces of the story.
+
+
+How is it made?
+===============
+
+There are two parts to the experiment. The server-side is just a wiki engine
+-- I have chosen to use my own wiki engine, Hatta, because I'm familiar with
+it and it has some nice features. In theory, any wiki engine could be used, as
+long as you could make it generate the right kind of HTML output for the map
+pages.
+
+For simplicity, the maps are just text files in format based in Windows INI
+files. This format is relatively easy to edit by hand while giving quite a bit
+of flexibility to the author. The exact syntax and options available are
+documented at the sample wiki at http://qqw.sheep.art.pl.
+
+The client-side part is just a couple of hundreds of lines of JavaScript code,
+to render the map graphically using browser's DOM and to animate the sprites.
+It was one of my assumptions that there should be nothing technically
+sophisticated in there, only very basic stuff is used -- this way the whole
+thing should even work on Microsoft Internet Explorer 6 (although I didn't
+test it on that browser). The position of the player (the entrance to the map)
+is stored as an URL fragment, and the list of items in the player character's
+inventory is stored in a cookie -- so that the items can be used outside the
+map on which they were acquired. No other state is kept.
+
+
+How to run your own?
+====================
+
+First you will need to have Python and the required Python modules, Werkzeug
+and Mercurial, installed -- you can get Python from http://python.org and
+install the requirements with ``pip install -r requirements.txt``. Once you do
+that, you can run the server with ``python qqw.py`` -- then just visit
+http://localhost:8080/ and make some map pages. It's also possible to run
+``qqw.py`` as a WSGI application -- that's how it is deployed on
+http://qqw.sheep.art.pl.
+
--- a/hatta.py	Sun Feb 17 20:53:59 2013 +0100
+++ b/hatta.py	Sun Feb 24 16:54:54 2013 +0100
@@ -39,6 +39,7 @@
 import werkzeug
 import werkzeug.exceptions
 import werkzeug.routing
+import werkzeug.contrib.atom
 
 try:
     import Image
@@ -64,15 +65,16 @@
 import mercurial.util
 import mercurial.hgweb
 
-__version__ = '1.3.3dev'
-name = 'Hatta'
-url = 'http://hatta-wiki.org/'
-description = 'Wiki engine that lives in Mercurial repository.'
+__version__ = '1.4.5'
+project_name = 'Hatta'
+project_url = 'http://hatta-wiki.org/'
+project_description = 'Wiki engine that lives in Mercurial repository.'
 
 mimetypes.add_type('application/x-python', '.wsgi')
 mimetypes.add_type('application/x-javascript', '.js')
 mimetypes.add_type('text/x-rst', '.rst')
 
+_ = lambda x: x # Later replaced with gettext initialization
 
 def external_link(addr):
     """
@@ -238,6 +240,8 @@
         add('-D', '--subdirectories', dest='subdirectories',
             action="store_true",
             help='Store subpages as subdirectories in the filesystem')
+        add('-U', '--unix-eol', dest='unix_eol', action="store_true",
+            help='Convert all text pages to UNIX-style CR newlines')
 
         options, args = parser.parse_args()
         for option, value in options.__dict__.iteritems():
@@ -347,7 +351,7 @@
     change history, using Mercurial repository as the storage method.
     """
 
-    def __init__(self, path, charset=None):
+    def __init__(self, path, charset=None, unix_eol=False):
         """
         Takes the path to the directory where the pages are to be kept.
         If the directory doen't exist, it will be created. If it's inside
@@ -356,6 +360,7 @@
         """
 
         self.charset = charset or 'utf-8'
+        self.unix_eol = unix_eol
         self.path = os.path.abspath(path)
         if not os.path.exists(self.path):
             os.makedirs(self.path)
@@ -464,18 +469,31 @@
 
         partial = lambda filename: repo_file == filename
         try:
-            unresolved = mercurial.merge.update(self.repo, tip_node,
-                                                True, True, partial)
+            mercurial.merge.update(self.repo, tip_node, True, True, partial)
             msg = _(u'merge of edit conflict')
         except mercurial.util.Abort:
-            unresolved = 1, 1, 1, 1
             msg = _(u'failed merge of edit conflict')
         self.repo.dirstate.setparents(tip_node, node)
         # Mercurial 1.1 and later need updating the merge state
         try:
-            mercurial.merge.mergestate(self.repo).mark(repo_file, "r")
-        except (AttributeError, KeyError):
+            mergestate = mercurial.merge.mergestate
+        except AttributeError:
             pass
+        else:
+            state = mergestate(self.repo)
+            try:
+                state.mark(repo_file, "r")
+            except KeyError:
+                # There were no conflicts to mark
+                pass
+            else:
+                # Mercurial 1.7+ needs a commit
+                try:
+                    commit = state.commit
+                except AttributeError:
+                    pass
+                else:
+                    commit()
         return msg
 
     @locked_repo
@@ -490,10 +508,16 @@
         mercurial.util.rename(file_name, file_path)
         changectx = self._changectx()
         try:
+            # Mercurial 1.5 and earlier have .add() on the repo
+            add = self.repo.add
+        except AttributeError:
+            # Mercurial 1.6
+            add = self.repo[None].add
+        try:
             filectx_tip = changectx[repo_file]
             current_page_rev = filectx_tip.filerev()
         except mercurial.revlog.LookupError:
-            self.repo.add([repo_file])
+            add([repo_file])
             current_page_rev = -1
         if parent is not None and current_page_rev != parent:
             msg = self.merge_changes(changectx, repo_file, text, user, parent)
@@ -537,6 +561,8 @@
         """Save text as specified page, encoded to charset."""
 
         data = text.encode(self.charset)
+        if self.unix_eol:
+            data = data.replace('\r\n', '\n')
         self.save_data(title, data, author, comment, parent)
 
     def page_text(self, title):
@@ -558,10 +584,20 @@
         file_path = self._file_path(title)
         self._check_path(file_path)
         try:
+            # Mercurial 1.5 and earlier have .remove() on the repo
+            remove = self.repo.remove
+        except AttributeError:
+            # Mercurial 1.6 and later
+            try:
+                remove = self.repo[None].remove
+            except AttributeError:
+                # Mercurial 1.9 and later
+                remove = self.repo[None].forget
+        try:
             os.unlink(file_path)
         except OSError:
             pass
-        self.repo.remove([repo_file])
+        remove([repo_file])
         self._commit([repo_file], text, user)
 
     def open_page(self, title):
@@ -619,16 +655,17 @@
         """Find the last revision in which the file existed."""
 
         repo_file = self._title_to_file(title)
-        changectx = self._changectx()
-        stack = [changectx]
-        while repo_file not in changectx:
-            if not stack:
+        stack = [self._changectx()]
+        while stack:
+            changectx = stack.pop()
+            if repo_file in changectx:
+                return changectx[repo_file]
+            if changectx.rev() == 0:
                 return None
-            changectx = stack.pop()
             for parent in changectx.parents():
                 if parent != changectx:
                     stack.append(parent)
-        return changectx[repo_file]
+        return None
 
     def page_history(self, title):
         """Iterate over the page's history."""
@@ -690,7 +727,9 @@
         """Iterate over the titles of all pages in the wiki."""
 
         for filename in os.listdir(self.path):
-            if (os.path.isfile(os.path.join(self.path, filename))
+            file_path = os.path.join(self.path, filename)
+            if (os.path.isfile(file_path)
+                and not os.path.islink(file_path)
                 and not filename.startswith('.')):
                 yield werkzeug.url_unquote(filename)
 
@@ -731,6 +770,7 @@
         path = os.path.join(self.repo_prefix, escaped)
         return path
 
+    @locked_repo
     def save_file(self, title, file_name, author=u'', comment=u'', parent=None):
         """
         Save the file and make the subdirectories if needed.
@@ -750,6 +790,7 @@
         super(WikiSubdirectoryStorage, self).save_file(title, file_name,
                                                        author, comment, parent)
 
+    @locked_repo
     def delete_page(self, title, author=u'', comment=u''):
         """
         Remove empty directories after deleting a page.
@@ -762,7 +803,8 @@
         file_path = self._file_path(title)
         self._check_path(file_path)
         dir_path = os.path.dirname(file_path)
-        os.removedirs(dir_path)
+        if dir_path != self.repo_path:
+            os.removedirs(dir_path)
 
     def all_pages(self):
         """Iterate over the titles of all pages in the wiki. Include subdirectories."""
@@ -802,6 +844,7 @@
         # "name": (priority, ur"pattern"),
         "bullets": (10, bullets_pat),
         "code": (20, ur"^[{][{][{]+\s*$"),
+        "math": (25, ur"^[$][$]\s*$"),
         "conflict": (30, ur"^<<<<<<< local\s*$"),
         "empty": (40, ur"^\s*$"),
         "heading": (50, heading_pat),
@@ -886,6 +929,7 @@
                                    for pat in patterns), re.U)
         self.code_close_re = re.compile(ur"^\}\}\}\s*$", re.U)
         self.macro_close_re = re.compile(ur"^>>\s*$", re.U)
+        self.math_close_re = re.compile(ur"^[$][$]\s*$", re.U)
         self.conflict_close_re = re.compile(ur"^>>>>>>> other\s*$", re.U)
         self.conflict_sep_re = re.compile(ur"^=======\s*$", re.U)
         self.image_re = re.compile(self.image_pat, re.U)
@@ -1075,6 +1119,12 @@
             yield werkzeug.html.pre(werkzeug.html(inside), class_="code",
                                     id="line_%d" % self.line_no)
 
+    def _block_math(self, block):
+        for self.line_no, part in block:
+            inside = u"\n".join(self.lines_until(self.math_close_re))
+            yield werkzeug.html.pre(werkzeug.html(inside), class_="math",
+                                    id="line_%d" % self.line_no)
+
     def _block_syntax(self, block):
         for self.line_no, part in block:
             syntax = part.lstrip('{#!').strip()
@@ -1285,9 +1335,9 @@
 ur"""┠┯┨┷┿┝┰┥┸╂"""
 ur"""ヲ-゚ぁ-ん~ーァ-ヶ"""
 ur"""0-9A-Za-z0-9A-Za-zΑ-Ωα-ωА-я]+""", re.UNICODE)
-    _con = {}
 
     def __init__(self, cache_path, lang, storage):
+        self._con = {}
         self.path = cache_path
         self.storage = storage
         self.lang = lang
@@ -1302,17 +1352,17 @@
         else:
             self.empty = False
         con = self.con # sqlite3.connect(self.filename)
-        self.con.execute('CREATE TABLE IF NOT EXISTS titles '
+        con.execute('CREATE TABLE IF NOT EXISTS titles '
                 '(id INTEGER PRIMARY KEY, title VARCHAR);')
-        self.con.execute('CREATE TABLE IF NOT EXISTS words '
+        con.execute('CREATE TABLE IF NOT EXISTS words '
                 '(word VARCHAR, page INTEGER, count INTEGER);')
-        self.con.execute('CREATE INDEX IF NOT EXISTS index1 '
+        con.execute('CREATE INDEX IF NOT EXISTS index1 '
                          'ON words (page);')
-        self.con.execute('CREATE INDEX IF NOT EXISTS index2 '
+        con.execute('CREATE INDEX IF NOT EXISTS index2 '
                          'ON words (word);')
-        self.con.execute('CREATE TABLE IF NOT EXISTS links '
+        con.execute('CREATE TABLE IF NOT EXISTS links '
                 '(src INTEGER, target INTEGER, label VARCHAR, number INTEGER);')
-        self.con.commit()
+        con.commit()
         self.stop_words_re = re.compile(u'^('+u'|'.join(re.escape(_(
 u"""am ii iii per po re a about above
 across after afterwards again against all almost alone along already also
@@ -1353,7 +1403,6 @@
             return self._con[thread_id]
         except KeyError:
             connection = sqlite3.connect(self.filename)
-            connection.isolation_level = None
             self._con[thread_id] = connection
             return connection
 
@@ -1396,11 +1445,13 @@
 
     def update_words(self, title, text, cursor):
         title_id = self.title_id(title, cursor)
+        cursor.execute('DELETE FROM words WHERE page=?;', (title_id,))
+        if not text:
+            return
         words = self.count_words(self.split_text(text))
         title_words = self.count_words(self.split_text(title))
         for word, count in title_words.iteritems():
             words[word] = words.get(word, 0) + count
-        cursor.execute('DELETE FROM words WHERE page=?;', (title_id,))
         for word, count in words.iteritems():
             cursor.execute('INSERT INTO words VALUES (?, ?, ?);',
                              (word, title_id, count))
@@ -1530,11 +1581,17 @@
             try:
                 text = get_text()
             except NotFoundErr:
-                text = u''
-        extract_links = getattr(page, 'extract_links', lambda x: [])
-        if text:
-            self.update_links(title, extract_links(text), cursor=cursor)
-        self.update_words(title, text, cursor=cursor)
+                text = None
+                title_id = self.title_id(title, cursor)
+                if not list(self.page_backlinks(title)):
+                    cursor.execute("DELETE FROM titles WHERE id=?;", (title_id,))
+        extract_links = getattr(page, 'extract_links', None)
+        if extract_links and text:
+            links = extract_links(text)
+        else:
+            links = []
+        self.update_links(title, links, cursor=cursor)
+        self.update_words(title, text or u'', cursor=cursor)
 
     def update_page(self, page, title, data=None, text=None):
         """Updates the index with new page content, for a single page."""
@@ -1542,28 +1599,26 @@
         if text is None and data is not None:
             text = unicode(data, self.storage.charset, 'replace')
         cursor = self.con.cursor()
-        cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
         try:
             self.set_last_revision(self.storage.repo_revision())
             self.reindex_page(page, title, cursor, text)
-            cursor.execute('COMMIT TRANSACTION;')
+            self.con.commit()
         except:
-            cursor.execute('ROLLBACK;')
+            self.con.rollback()
             raise
 
     def reindex(self, wiki, request,  pages):
         """Updates specified pages in bulk."""
 
         cursor = self.con.cursor()
-        cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
         try:
             for title in pages:
                 page = wiki.get_page(request, title)
                 self.reindex_page(page, title, cursor)
-            cursor.execute('COMMIT TRANSACTION;')
+            self.con.commit()
             self.empty = False
         except:
-            cursor.execute('ROLLBACK;')
+            self.con.rollback()
             raise
 
     def set_last_revision(self, rev):
@@ -1606,19 +1661,19 @@
             self.response = []
             try:
                 del self.content_type
-            except AttributeError:
+            except (AttributeError, KeyError, IndexError):
                 pass
             try:
                 del self.content_length
-            except AttributeError:
+            except (AttributeError, KeyError, IndexError):
                 pass
             try:
                 del self.headers['Content-length']
-            except (KeyError, IndexError):
+            except (AttributeError, KeyError, IndexError):
                 pass
             try:
                 del self.headers['Content-type']
-            except (KeyError, IndexError):
+            except (AttributeError, KeyError, IndexError):
                 pass
         return ret
 
@@ -1756,32 +1811,36 @@
         else:
             classes = []
         if external_link(addr):
+            classes.append('external')
             if addr.startswith('mailto:'):
-                class_ = 'external email'
+                classes.append('email')
                 text = text.replace('@', '&#64;').replace('.', '&#46;')
-                href = addr.replace('@', '%40').replace('.', '%2E')
+                href = werkzeug.escape(addr,
+                                       True).replace('@',
+                                                     '%40').replace('.', '%2E')
             else:
-                classes.append('external')
-                href = werkzeug.escape(addr, quote=True)
+                href = werkzeug.escape(werkzeug.url_fix(addr), quote=True)
         else:
             if '#' in addr:
                 addr, chunk = addr.split('#', 1)
-                chunk = '#'+chunk
+                chunk = '#' + werkzeug.url_fix(chunk)
             if addr.startswith('+'):
                 href = '/'.join([self.request.script_root,
                                  '+'+werkzeug.escape(addr[1:], quote=True)])
                 classes.append('special')
             elif addr == u'':
-                href = chunk
+                href = werkzeug.escape(chunk, True)
                 classes.append('anchor')
             else:
                 classes.append('wiki')
-                href = self.get_url(addr) + chunk
+                href = werkzeug.escape(self.get_url(addr) + chunk, True)
                 if addr not in self.storage:
                     classes.append('nonexistent')
-        class_ = ' '.join(classes) or None
-        return werkzeug.html.a(image or text, href=href, class_=class_,
-                               title=addr+chunk)
+        class_ = werkzeug.escape(' '.join(classes) or '', True)
+        # We need to output HTML on our own to prevent escaping of href
+        return '<a href="%s" class="%s" title="%s">%s</a>' % (
+                href, class_, werkzeug.escape(addr + chunk, True),
+                image or text)
 
     def wiki_image(self, addr, alt, class_='wiki', lineno=0):
         """Create HTML for a wiki image."""
@@ -1822,7 +1881,6 @@
     def menu(self):
         """Generate the menu items"""
 
-        html = werkzeug.html
         if self.wiki.menu_page in self.storage:
             items = self.index.page_links_and_labels(self.wiki.menu_page)
         else:
@@ -1862,9 +1920,6 @@
         yield h.link(rel="shortcut icon", type_="image/x-icon",
                      href=self.get_url(None, self.wiki.favicon_ico))
         yield h.link(rel="alternate", type_="application/rss+xml",
-                     title=e("%s (RSS)" % self.wiki.site_name),
-                     href=self.get_url(None, self.wiki.rss))
-        yield h.link(rel="alternate", type_="application/rss+xml",
                      title=e("%s (ATOM)" % self.wiki.site_name),
                      href=self.get_url(None, self.wiki.atom))
         yield h.script(type_="text/javascript",
@@ -1966,7 +2021,8 @@
                 button = h.input(type_="submit", name=str(rev),
                                  value=h(_(u'Undo')))
             yield h.li(h.a(self.date_html(date), href=date_url),
-                       button, ' . . . . ', h.i(self.wiki_link(author)),
+                       button, ' . . . . ',
+                       h.i(self.wiki_link("~%s" % author, author)),
                        h.div(h(comment), class_="comment"))
         yield u'</ul>'
         yield h.input(type_="hidden", name="parent", value=max_rev)
@@ -2070,7 +2126,6 @@
                 yield None
 
         diff = difflib._mdiff(from_text.split('\n'), to_text.split('\n'))
-        stack = []
         mark_re = re.compile('\0[-+^]([^\1\0]*)\1|([^\0\1])')
         yield message
         yield u'<pre class="diff">'
@@ -2132,7 +2187,7 @@
         formatter = pygments.formatters.HtmlFormatter()
         formatter.line_no = line_no
 
-        def wrapper(source, outfile):
+        def wrapper(source, unused_outfile):
             """Wrap each line of formatted output."""
 
             yield 0, '<div class="highlight"><pre>'
@@ -2310,7 +2365,7 @@
                                                  for cell in row))
         except csv.Error, e:
             yield u'</table>'
-            yield werkzeug.p(werkzeug.html(
+            yield werkzeug.html.p(werkzeug.html(
                 _(u'Error parsing CSV file %{file}s on line %{line}d: %{error}s')
                 % {'file': html_title, 'line': reader.line_num, 'error': e}))
         finally:
@@ -2364,9 +2419,9 @@
                 if in_header:
                     if in_bug:
                         yield '</div>'
-                    tags = [tag.strip() for tag in
-                            attributes.get('tags', '').split()
-                            if tag.strip()]
+                    #tags = [tag.strip() for tag in
+                    #        attributes.get('tags', '').split()
+                    #        if tag.strip()]
                     yield '<div id="line_%d">' % (line_no)
                     in_bug = True
                     if title:
@@ -2401,19 +2456,48 @@
 
 
 class WikiTitleConverter(werkzeug.routing.PathConverter):
-    """Behaves like the path converter, except that it escapes slashes."""
+    """Behaves like the path converter, but doesn't match the "+ pages"."""
 
     def to_url(self, value):
-        return werkzeug.url_quote(value.strip(), self.map.charset, safe="")
+        return werkzeug.url_quote(value.strip(), self.map.charset, safe="/")
 
     regex='([^+%]|%[^2]|%2[^Bb]).*'
 
+
 class WikiAllConverter(werkzeug.routing.BaseConverter):
     """Matches everything."""
 
     regex='.*'
 
 
+class URL(object):
+    """A decorator for marking methods as endpoints for URLs."""
+
+    urls = []
+
+    def __init__(self, url, methods=None):
+        """Create a decorator with specified parameters."""
+
+        self.url = url
+        self.methods = methods or ['GET', 'HEAD']
+
+    def __call__(self, func):
+        """The actual decorator only records the data."""
+
+        self.urls.append((func.__name__, self.url, self.methods))
+        return func
+
+    @classmethod
+    def rules(cls, app):
+        """Returns the routing rules, using app's bound methods."""
+
+        for name, url, methods in cls.urls:
+            func = getattr(app, name, None)
+            if not callable(func):
+                continue
+            yield werkzeug.routing.Rule(url, endpoint=func, methods=methods)
+
+
 class Wiki(object):
     """
     The main class of the wiki, handling initialization of the whole
@@ -2564,65 +2648,37 @@
         self.icon_page = self.config.get('icon_page', None)
         self.pygments_style = self.config.get('pygments_style', 'tango')
         self.subdirectories = self.config.get_bool('subdirectories', False)
+        self.unix_eol = self.config.get_bool('unix_eol', False)
         if self.subdirectories:
-            self.storage = WikiSubdirectoryStorage(self.path, self.page_charset)
+            self.storage = WikiSubdirectoryStorage(self.path, self.page_charset,
+                    unix_eol=self.unix_eol)
         else:
-            self.storage = self.storage_class(self.path, self.page_charset)
+            self.storage = self.storage_class(self.path, self.page_charset,
+                    unix_eol=self.unix_eol)
         self.cache = config.get('cache_path', None)
         if self.cache is None:
             self.cache = os.path.join(self.storage.repo_path, '.hg', 'hatta', 'cache')
         self.cache = os.path.abspath(self.cache)
         if not os.path.isdir(self.cache):
             os.makedirs(self.cache)
-            reindex = True
-        else:
-            reindex = False
+#            reindex = True
+#        else:
+#            reindex = False
         self.index = self.index_class(self.cache, self.language, self.storage)
-        R = werkzeug.routing.Rule
-        self.url_map = werkzeug.routing.Map([
-            R('/', defaults={'title': self.front_page},
-              endpoint=self.view, methods=['GET', 'HEAD']),
-            R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
-            R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
-            R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
-            R('/+history/', endpoint=self.recent_changes,
-              methods=['GET', 'HEAD']),
-            R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
-              endpoint=self.diff, methods=['GET', 'HEAD']),
-            R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
-              methods=['GET', 'HEAD']),
-            R('/+history/<title:title>', endpoint=self.history,
-              methods=['GET', 'HEAD']),
-            R('/+version/', endpoint=self.version, methods=['GET', 'HEAD']),
-            R('/+version/<title:title>', endpoint=self.version,
-              methods=['GET', 'HEAD']),
-            R('/+download/<title:title>', endpoint=self.download,
-              methods=['GET', 'HEAD']),
-            R('/+render/<title:title>', endpoint=self.render,
-              methods=['GET', 'HEAD']),
-            R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
-            R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
-            R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
-            R('/+index', endpoint=self.all_pages, methods=['GET', 'HEAD']),
-            R('/+orphaned', endpoint=self.orphaned, methods=['GET', 'HEAD']),
-            R('/+wanted', endpoint=self.wanted, methods=['GET', 'HEAD']),
-            R('/+search', endpoint=self.search, methods=['GET', 'POST']),
-            R('/+search/<title:title>', endpoint=self.backlinks,
-              methods=['GET', 'POST']),
-            R('/off-with-his-head', endpoint=self.die, methods=['GET']),
-            R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
-              methods=['GET', 'POST', 'HEAD']),
-            # Pages with default content
-            R('/favicon.ico', endpoint=self.favicon_ico,
-              methods=['GET', 'HEAD']),
-            R('/robots.txt', endpoint=self.robots_txt, methods=['GET', 'HEAD']),
-            R('/+download/style.css', endpoint=self.style_css,
-              methods=['GET', 'HEAD']),
-            R('/+download/pygments.css', endpoint=self.pygments_css,
-              methods=['GET', 'HEAD']),
-            R('/+download/scripts.js', endpoint=self.scripts_js,
-              methods=['GET', 'HEAD']),
-        ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
+        self.url_rules = URL.rules(self)
+        self.url_map = werkzeug.routing.Map(self.url_rules, converters={
+            'title':WikiTitleConverter,
+            'all':WikiAllConverter
+        })
+
+    def add_url_rule(self, rule):
+        """Let plugins add additional url rules."""
+
+        self.url_rules.append(rule)
+        self.url_map = werkzeug.routing.Map(self.url_rules, converters={
+            'title':WikiTitleConverter,
+            'all':WikiAllConverter
+        })
 
     def get_page(self, request, title):
         """Creates a page object based on page's mime type"""
@@ -2654,7 +2710,53 @@
             mime = ''
         return page_class(self, request, title, mime)
 
-    def view(self, request, title):
+    def response(self, request, title, content, etag='', mime='text/html',
+                 rev=None, size=None):
+        """Create a WikiResponse for a page."""
+
+        response = WikiResponse(content, mimetype=mime)
+        if rev is None:
+            inode, _size, mtime = self.storage.page_file_meta(title)
+            response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
+                                                inode, mtime))
+            if size == -1:
+                size = _size
+        else:
+            response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
+                                             rev))
+        if size:
+            response.content_length = size
+        response.make_conditional(request)
+        return response
+
+    def _check_lock(self, title):
+        restricted_pages = [
+            'scripts.js',
+            'robots.txt',
+        ]
+        if self.read_only:
+            raise ForbiddenErr(_(u"This site is read-only."))
+        if title in restricted_pages:
+            raise ForbiddenErr(_(u"""Can't edit this page.
+It can only be edited by the site admin directly on the disk."""))
+        if title in self.index.page_links(self.locked_page):
+            raise ForbiddenErr(_(u"This page is locked."))
+
+    def _serve_default(self, request, title, content, mime):
+        """Some pages have their default content."""
+
+        if title in self.storage:
+            return self.download(request, title)
+        response = WikiResponse(content, mimetype=mime)
+        response.set_etag('/%s/-1' % title)
+        response.make_conditional(request)
+        return response
+
+    @URL('/<title:title>')
+    @URL('/')
+    def view(self, request, title=None):
+        if title is None:
+            title = self.front_page
         page = self.get_page(request, title)
         try:
             content = page.view_content()
@@ -2666,6 +2768,7 @@
         etag = '/(%s)' % u','.join(dependencies)
         return self.response(request, title, html, etag=etag)
 
+    @URL('/+history/<title:title>/<int:rev>')
     def revision(self, request, title, rev):
         text = self.storage.revision_text(title, rev)
         link = werkzeug.html.a(werkzeug.html(title),
@@ -2683,6 +2786,8 @@
         response = self.response(request, title, html, rev=rev, etag='/old')
         return response
 
+    @URL('/+version/')
+    @URL('/+version/<title:title>')
     def version(self, request, title=None):
         if title is None:
             version = self.storage.repo_revision()
@@ -2693,19 +2798,7 @@
                 version = 0
         return werkzeug.Response('%d' % version, mimetype="text/plain")
 
-    def _check_lock(self, title):
-        restricted_pages = [
-            'scripts.js',
-            'robots.txt',
-        ]
-        if self.read_only:
-            raise ForbiddenErr(_(u"This site is read-only."))
-        if title in restricted_pages:
-            raise ForbiddenErr(_(u"""Can't edit this page.
-It can only be edited by the site admin directly on the disk."""))
-        if title in self.index.page_links(self.locked_page):
-            raise ForbiddenErr(_(u"This page is locked."))
-
+    @URL('/+edit/<title:title>', methods=['POST'])
     def save(self, request, title):
         self._check_lock(title)
         url = request.get_url(title)
@@ -2765,6 +2858,7 @@
                             max_age=604800)
         return response
 
+    @URL('/+edit/<title:title>', methods=['GET'])
     def edit(self, request, title, preview=None):
         self._check_lock(title)
         exists = title in self.storage
@@ -2785,156 +2879,40 @@
         response.headers.add('Cache-Control', 'no-cache')
         return response
 
+    @URL('/+feed/atom')
+    @URL('/+feed/rss')
     def atom(self, request):
-        date_format = "%Y-%m-%dT%H:%M:%SZ"
-        first_date = datetime.datetime.now()
-        now = first_date.strftime(date_format)
-        body = []
-        first_title = u''
-        count = 0
-        unique_titles = {}
-        for title, rev, date, author, comment in self.storage.history():
+        feed = werkzeug.contrib.atom.AtomFeed(self.site_name,
+            feed_url=request.url,
+            url=request.adapter.build(self.view, force_external=True),
+            subtitle=_(u'Track the most recent changes to the wiki '
+                       u'in this feed.'))
+        history = itertools.islice(self.storage.history(), None, 10, None)
+        unique_titles = set()
+        for title, rev, date, author, comment in history:
             if title in unique_titles:
                 continue
-            unique_titles[title] = True
-            count += 1
-            if count > 10:
-                break
-            if not first_title:
-                first_title = title
-                first_rev = rev
-                first_date = date
-            item = u"""<entry>
-    <title>%(title)s</title>
-    <link href="%(page_url)s" />
-    <content>%(comment)s</content>
-    <updated>%(date)s</updated>
-    <author>
-        <name>%(author)s</name>
-        <uri>%(author_url)s</uri>
-    </author>
-    <id>%(url)s</id>
-</entry>""" % {
-                'title': werkzeug.escape(title),
-                'page_url': request.adapter.build(self.view, {'title': title},
-                                                  force_external=True),
-                'comment': werkzeug.escape(comment),
-                'date': date.strftime(date_format),
-                'author': werkzeug.escape(author),
-                'author_url': request.adapter.build(self.view,
-                                                    {'title': author},
-                                                    force_external=True),
-                'url': request.adapter.build(self.revision,
-                                             {'title': title, 'rev': rev},
-                                             force_external=True),
-            }
-            body.append(item)
-        content = u"""<?xml version="1.0" encoding="utf-8"?>
-<feed xmlns="http://www.w3.org/2005/Atom">
-  <title>%(title)s</title>
-  <link rel="self" href="%(atom)s"/>
-  <link href="%(home)s"/>
-  <id>%(home)s</id>
-  <updated>%(date)s</updated>
-  <logo>%(logo)s</logo>
-%(body)s
-</feed>""" % {
-            'title': self.site_name,
-            'home': request.adapter.build(self.view, force_external=True),
-            'atom': request.adapter.build(self.atom, force_external=True),
-            'date': first_date.strftime(date_format),
-            'logo': request.adapter.build(self.download,
-                                          {'title': self.logo_page},
-                                          force_external=True),
-            'body': u''.join(body),
-        }
-        response = self.response(request, 'atom', content, '/atom',
-                                 'application/xml', first_rev, first_date)
-        response.set_etag('/atom/%d' % self.storage.repo_revision())
+            unique_titles.add(title)
+            if rev>0:
+                url = request.adapter.build(self.diff, {
+                    'title': title,
+                    'from_rev': rev-1,
+                    'to_rev': rev
+                }, force_external=True)
+            else:
+                url = request.adapter.build(self.revision, {
+                    'title': title,
+                    'rev': rev,
+                }, force_external=True)
+            feed.add(title, comment, content_type="text", author=author,
+                     url=url, updated=date)
+        rev = self.storage.repo_revision()
+        response = self.response(request, 'atom', feed.generate(), '/+feed',
+                                 'application/xml', rev)
         response.make_conditional(request)
         return response
 
-    def rss(self, request):
-        """Serve an RSS feed of recent changes."""
-
-        first_date = datetime.datetime.now()
-        now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
-        rss_body = []
-        first_title = u''
-        count = 0
-        unique_titles = {}
-        for title, rev, date, author, comment in self.storage.history():
-            if title in unique_titles:
-                continue
-            unique_titles[title] = True
-            count += 1
-            if count > 10:
-                break
-            if not first_title:
-                first_title = title
-                first_rev = rev
-                first_date = date
-            item = (u'<item><title>%s</title><link>%s</link>'
-                    u'<description>%s</description><pubDate>%s</pubDate>'
-                    u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
-                werkzeug.escape(title),
-                request.adapter.build(self.view, {'title': title},
-                                                  force_external=True),
-                werkzeug.escape(comment),
-                date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
-                werkzeug.escape(author),
-                request.adapter.build(self.revision,
-                                      {'title': title, 'rev': rev})
-            ))
-            rss_body.append(item)
-        rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
-<rss version="2.0"
-xmlns:dc="http://purl.org/dc/elements/1.1/"
-xmlns:atom="http://www.w3.org/2005/Atom"
->
-<channel>
-    <title>%s</title>
-    <atom:link href="%s" rel="self" type="application/rss+xml" />
-    <link>%s</link>
-    <description>%s</description>
-    <generator>Hatta Wiki</generator>
-    <language>en</language>
-    <lastBuildDate>%s</lastBuildDate>
-
-""" % (
-            werkzeug.escape(self.site_name),
-            request.adapter.build(self.rss),
-            request.adapter.build(self.recent_changes),
-            werkzeug.escape(_(u'Track the most recent changes to the wiki '
-                              u'in this feed.')),
-            first_date,
-        )
-        content = [rss_head]+rss_body+[u'</channel></rss>']
-        response = self.response(request, 'rss', content, '/rss',
-                                 'application/xml', first_rev, first_date)
-        response.set_etag('/rss/%d' % self.storage.repo_revision())
-        response.make_conditional(request)
-        return response
-
-    def response(self, request, title, content, etag='', mime='text/html',
-                 rev=None, date=None, size=None):
-        """Create a WikiResponse for a page."""
-
-        response = WikiResponse(content, mimetype=mime)
-        if rev is None:
-            inode, _size, mtime = self.storage.page_file_meta(title)
-            response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
-                                                inode, mtime))
-            if size == -1:
-                size = _size
-        else:
-            response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
-                                             rev))
-        if size:
-            response.content_length = size
-        response.make_conditional(request)
-        return response
-
+    @URL('/+download/<title:title>')
     def download(self, request, title):
         """Serve the raw content of a page directly from disk."""
 
@@ -2950,6 +2928,7 @@
         response.direct_passthrough = True
         return response
 
+    @URL('/+render/<title:title>')
     def render(self, request, title):
         """Serve a thumbnail or otherwise rendered content."""
 
@@ -3014,6 +2993,7 @@
         response.direct_passthrough = True
         return response
 
+    @URL('/+undo/<title:title>', methods=['POST'])
     def undo(self, request, title):
         """Revert a change to a page."""
 
@@ -3047,6 +3027,7 @@
                                     method='GET', force_external=True)
         return werkzeug.redirect(url, 303)
 
+    @URL('/+history/<title:title>')
     def history(self, request, title):
         """Display history of changes of a page."""
 
@@ -3056,7 +3037,7 @@
         response = self.response(request, title, content, '/history')
         return response
 
-
+    @URL('/+history/')
     def recent_changes(self, request):
         """Serve the recent changes page."""
 
@@ -3091,7 +3072,7 @@
 
                 yield h.li(h.a(page.date_html(date), href=date_url), ' ',
                     h.b(page.wiki_link(title)), u' . . . . ',
-                    h.i(page.wiki_link(author)),
+                    h.i(page.wiki_link('~%s' % author, author)),
                     h.div(h(comment), class_="comment")
                 )
             yield u'</ul>'
@@ -3103,6 +3084,7 @@
         response.make_conditional(request)
         return response
 
+    @URL('/+history/<title:title>/<int:from_rev>:<int:to_rev>')
     def diff(self, request, title, from_rev, to_rev):
         """Show the differences between specified revisions."""
 
@@ -3133,6 +3115,7 @@
         return response
 
 
+    @URL('/+index')
     def all_pages(self, request):
         """Show index of all pages in the wiki."""
 
@@ -3145,6 +3128,7 @@
         response.make_conditional(request)
         return response
 
+    @URL('/+orphaned')
     def orphaned(self, request):
         """Show all pages that don't have backlinks."""
 
@@ -3158,6 +3142,7 @@
         response.make_conditional(request)
         return response
 
+    @URL('/+wanted')
     def wanted(self, request):
         """Show all pages that don't exist yet, but are linked."""
 
@@ -3183,6 +3168,7 @@
         response.make_conditional(request)
         return response
 
+    @URL('/+search', methods=['GET', 'POST'])
     def search(self, request):
         """Serve the search results page."""
 
@@ -3236,6 +3222,7 @@
         html = page.render_content(content, title)
         return WikiResponse(html, mimetype='text/html')
 
+    @URL('/+search/<title:title>', methods=['GET', 'POST'])
     def backlinks(self, request, title):
         """Serve the page with backlinks."""
 
@@ -3252,27 +3239,21 @@
         response.make_conditional(request)
         return response
 
-    def _serve_default(self, request, title, content, mime):
-        """Some pages have their default content."""
-
-        if title in self.storage:
-            return self.download(request, title)
-        response = werkzeug.Response(content, mimetype=mime)
-        response.set_etag('/%s/-1' % title)
-        response.make_conditional(request)
-        return response
-
+    @URL('/+download/scripts.js')
     def scripts_js(self, request):
         """Server the default scripts"""
 
         return self._serve_default(request, 'scripts.js', self.scripts,
                                    'text/javascript')
 
+    @URL('/+download/style.css')
     def style_css(self, request):
         """Serve the default style"""
 
         return self._serve_default(request, 'style.css', self.style,
                                    'text/css')
+
+    @URL('/+download/pygments.css')
     def pygments_css(self, request):
         """Serve the default pygments style"""
 
@@ -3287,12 +3268,14 @@
         return self._serve_default(request, 'pygments.css', style_defs,
                                    'text/css')
 
+    @URL('/favicon.ico')
     def favicon_ico(self, request):
         """Serve the default favicon."""
 
         return self._serve_default(request, 'favicon.ico', self.icon,
                                    'image/x-icon')
 
+    @URL('/robots.txt')
     def robots_txt(self, request):
         """Serve the robots directives."""
 
@@ -3308,6 +3291,7 @@
         return self._serve_default(request, 'robots.txt', robots,
                                    'text/plain')
 
+    @URL('/+hg<all:path>', methods=['GET', 'POST', 'HEAD'])
     def hgweb(self, request, path=None):
         """Serve the pages repository on the web like a normal hg repository."""
 
@@ -3324,6 +3308,7 @@
             return app(env, start)
         return hg_app
 
+    @URL('/off-with-his-head', methods=['GET'])
     def die(self, request):
         """Terminate the standalone server if invoked from localhost."""
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/requirements.txt	Sun Feb 24 16:54:54 2013 +0100
@@ -0,0 +1,2 @@
+werkzeug
+mercurial