constance

Scripts for generating (an earlier obsolete version of) my personal web site
git clone https://code.djc.id.au/git/constance/
commit 7d1d495ac8a06e12d999fc767ee20928346550d6
parent 1d9ba128652924ca1219507ca403665a5f93f304
Author: Dan Callaghan <djc@djc.id.au>
Date:   Sun, 15 Aug 2010 13:17:27 +1000

new incarnation: constance is now a bunch of helper scripts for generating a static site, rather than a WSGI app

--HG--
rename : itemtypes.py => blog.py
rename : app.py => constance.py
rename : itemtypes.py => reading.py
rename : templates/atom/BlogEntry.xml => templates/blog/entry.atom
rename : templates/html/BlogEntry.xml => templates/blog/entry.html
rename : templates/atom/multiple.xml => templates/blog/index.atom
rename : templates/atom/ReadingLogEntry.xml => templates/reading/entry.atom
rename : templates/html/tag_cloud.xml => templates/tags/index.html

Diffstat:
Dapp.py | 220-------------------------------------------------------------------------------
Ablog.py | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dconfig.defaults | 61-------------------------------------------------------------
Dconfig.py | 15---------------
Aconstance.py | 42++++++++++++++++++++++++++++++++++++++++++
Ddoc/deploy.txt | 15---------------
Ahomepage.py | 24++++++++++++++++++++++++
Ditemtypes.py | 231-------------------------------------------------------------------------------
Areading.py | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Dstatic/css/common.css | 185-------------------------------------------------------------------------------
Dstatic/css/tag_cloud.css | 19-------------------
Dstatic/images/feed-icon-16x16.png | 0
Dstatic/images/star-half.png | 0
Dstatic/images/star-off.png | 0
Dstatic/images/star.png | 0
Atags.py | 28++++++++++++++++++++++++++++
Dtemplates/atom/BlogEntry.xml | 27---------------------------
Dtemplates/atom/ReadingLogEntry.xml | 26--------------------------
Dtemplates/atom/multiple.xml | 21---------------------
Atemplates/blog/entry.atom | 25+++++++++++++++++++++++++
Atemplates/blog/entry.html | 30++++++++++++++++++++++++++++++
Atemplates/blog/index.atom | 21+++++++++++++++++++++
Atemplates/blog/index.html | 28++++++++++++++++++++++++++++
Atemplates/homepage/firehose.atom | 21+++++++++++++++++++++
Atemplates/homepage/index.html | 43+++++++++++++++++++++++++++++++++++++++++++
Dtemplates/html/BlogEntry.xml | 28----------------------------
Dtemplates/html/ReadingLogEntry.xml | 35-----------------------------------
Dtemplates/html/_commonwrapper.xml | 48------------------------------------------------
Dtemplates/html/_fragments.xml | 13-------------
Dtemplates/html/multiple.xml | 46----------------------------------------------
Dtemplates/html/single.xml | 70----------------------------------------------------------------------
Dtemplates/html/tag_cloud.xml | 24------------------------
Atemplates/reading/entry.atom | 26++++++++++++++++++++++++++
Atemplates/reading/reading.atom | 21+++++++++++++++++++++
Atemplates/reading/reading.html | 47+++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/tags/index.html | 21+++++++++++++++++++++
Atemplates/tags/tag.html | 24++++++++++++++++++++++++
37 files changed, 536 insertions(+), 1084 deletions(-)
diff --git a/app.py b/app.py
@@ -1,220 +0,0 @@
-
-# vim:encoding=utf-8
-
-import os, sys
-sys.path.insert(0, os.path.dirname(__file__))
-sys.path.insert(1, os.path.join(os.path.dirname(__file__), 'lib'))
-
-import cgi, re, datetime, urllib
-from itertools import chain
-import genshi.template
-from webob import Request, Response
-from webob import exc
-from recaptcha.client import captcha
-
-import config
-from itemtypes import *
-
-template_loader = genshi.template.TemplateLoader(
-        os.path.join(os.path.dirname(__file__), 'templates'), 
-        variable_lookup='strict', 
-        auto_reload=True)
-
-class Constance(object):
-
-    def __init__(self, environ, start_response):
-        self.environ = environ
-        self.start = start_response
-
-        self.config = config.ConstanceConfigParser(self.environ['constance.config_filename'])
-        self.encoding = self.config.get('global', 'encoding')
-
-        self.req = Request(environ)
-        self.req.charset = self.encoding
-
-        self.item_sets = eval(self.config.get('global', 'item_sets'))
-
-    def __iter__(self):
-        try:
-            resp = self.dispatch()
-        except exc.HTTPException, e:
-            resp = e
-        return iter(resp(self.environ, self.start))
-
-    urls = [(r'/(?:index)?$', 'index'), 
-            (r'/tags/$', 'tag_cloud'), 
-            (r'/tags/(.+)$', 'tag'), 
-            (r'/sitemap.xml$', 'sitemap')]
-    urls = [(re.compile(patt), method) for patt, method in urls]
-    def dispatch(self):
-        if self.req.path_info.endswith('.atom'):
-            self.req.format = 'application/atom+xml'
-            self.req.path_info = self.req.path_info[:-5]
-        else:
-            self.req.format = self.req.accept.best_match(['text/html', 'application/atom+xml']) # XXX don't hardcode
-
-        path_info = urllib.unquote(self.req.path_info).decode(self.encoding)
-
-        # first, try the predefined urls above
-        for patt, method in self.urls:
-            m = patt.match(path_info)
-            if m is not None:
-                return getattr(self, method)(*m.groups())
-
-        # next, try the item sets
-        for item_set in self.item_sets:
-            try:
-                result = item_set.get(path_info)
-            except NotExistError, e:
-                pass
-            else:
-                if hasattr(result, '__iter__'):
-                    return self.render_multiple(result)
-                else:
-                    return self.render_single(result)
-
-        # no matching URI found, so give a 404
-        raise exc.HTTPNotFound().exception
-
-    def render_single(self, item):
-        if self.req.format == 'text/html':
-            template = template_loader.load('html/single.xml')
-            rendered = template.generate(
-                    config=self.config, 
-                    item=item
-                    ).render('xhtml')
-        else:
-            raise exc.HTTPBadRequest('Unacceptable format for render_single %r' % self.req.format).exception
-        return Response(rendered, content_type=self.req.format)
-
-    def render_multiple(self, items):
-        try:
-            offset = int(self.req.GET.get('offset', 0))
-        except ValueError:
-            raise exc.HTTPBadRequest('Invalid offset %r' % self.GET['offset']).exception
-        if self.req.format == 'text/html':
-            template = template_loader.load('html/multiple.xml')
-            rendered = template.generate(
-                    config=self.config, 
-                    items=items, 
-                    title=None, 
-                    offset=offset
-                    ).render('xhtml')
-        elif self.req.format == 'application/atom+xml':
-            template = template_loader.load('atom/multiple.xml')
-            rendered = template.generate(
-                    config=self.config, 
-                    items=items, 
-                    title=None, 
-                    self_url=self.req.path_url
-                    ).render('xml')
-        else:
-            raise exc.HTTPBadRequest('Unacceptable format for render_multiple %r' % self.req.format).exception
-        return Response(rendered, content_type=self.req.format)
-
-    def index(self):
-        items = chain(*self.item_sets)
-        return self.render_multiple(items)
-    
-    def tag_cloud(self):
-        tag_freqs = {}
-        for entry in chain(*self.item_sets):
-            for tag in entry.tags:
-                tag_freqs[tag] = tag_freqs.get(tag, 0) + 1
-        if self.req.format == 'text/html':
-            rendered = template_loader.load('html/tag_cloud.xml').generate(
-                    config=self.config, 
-                    environ=self.environ, 
-                    tag_freqs=tag_freqs
-                    ).render('xhtml', encoding=self.encoding)
-        else:
-            raise exc.HTTPBadRequest('Unacceptable format for render_multiple %r' % self.req.format).exception
-        return Response(rendered, content_type=self.req.format)
-    
-    def add_post_comment(self, id):
-        entry = self.blog_entries[id]
-
-        if self.config.getboolean('blog', 'require_captcha'):
-            # first verify the captcha
-            if ('recaptcha_challenge_field' not in self.req.POST or 
-                    'recaptcha_response_field' not in self.req.POST):
-                raise exc.HTTPForbidden('CAPTCHA form values missing. Are you a bot?').exception
-            captcha_response = captcha.submit(
-                    self.req.POST['recaptcha_challenge_field'], 
-                    self.req.POST['recaptcha_response_field'], 
-                    self.config.get('blog', 'recaptcha_privkey'), 
-                    self.req.remote_addr)
-            if not captcha_response.is_valid:
-                raise exc.HTTPForbidden('You failed the CAPTCHA. Please try submitting again. '
-                        '(reCAPTCHA error code: %s)' % captcha_response.error_code).exception
-
-        try:
-            metadata = {}
-            metadata['From'] = self.req.POST['from'] or u'Anonymous'
-            if self.req.POST['author-url']:
-                metadata['Author-URL'] = self.req.POST['author-url']
-            if self.req.POST['author-email']:
-                metadata['Author-Email'] = self.req.POST['author-email']
-            if self.req.headers['User-Agent']:
-                metadata['User-Agent'] = self.req.headers['User-Agent']
-            if self.req.remote_addr:
-                metadata['Received'] = u'from %s' % self.req.remote_addr
-            entry.add_comment(metadata, self.req.POST['comment'])
-            raise exc.HTTPFound('%s/%s/' % (self.req.application_url, 
-                    id.encode(self.encoding))).exception
-        except blog.CommentingForbiddenError:
-            raise exc.HTTPForbidden('Commenting is disabled for this entry.').exception
-
-    def tag(self, tag):
-        with_tag = [e for e in chain(*self.item_sets) if tag in e.tags]
-        if not with_tag:
-            raise exc.HTTPNotFound().exception
-        return self.render_multiple(with_tag)
-
-    def sitemap(self):
-        tags = {}
-        for entry in self.blog_entries:
-            for tag in entry.tags:
-                tags[tag] = max(entry.modified_date, tags.get(tag, datetime.datetime.min))
-        sorted_blog_entries = sorted(self.blog_entries, 
-                key=lambda e: e.publication_date, reverse=True)
-        sorted_entries = sorted(chain(self.blog_entries, self.readinglog_entries), 
-                key=lambda e: e.publication_date, reverse=True)
-        readinglog_entries = list(self.readinglog_entries)
-        if len(readinglog_entries) != 0:
-            rl_updated = max(e.date for e in readinglog_entries)
-        else:
-            rl_updated = None
-        rendered = template_loader.load('sitemap.xml').generate(
-                config=self.config, 
-                environ=self.environ, 
-                blog_entries=self.blog_entries, 
-                tags=tags, 
-                readinglog_updated=rl_updated,
-                blog_index_updated=max(e.modified_date for e in sorted_blog_entries[:self.config.getint('global', 'entries_per_page')]), 
-                index_updated=max(e.modified_date for e in sorted_entries[:self.config.getint('global', 'entries_per_page')]), 
-                ).render('xml', encoding='utf8') # sitemaps must be UTF-8
-        return Response(rendered, content_type='text/xml')
-
-application = Constance
-
-if __name__ == '__main__':
-    import optparse
-    parser = optparse.OptionParser(usage='%prog [OPTIONS...] CONFIG_FILENAME')
-    parser.set_defaults(port=8082)
-    parser.add_option('-p', '--port', type='int', 
-            help='Port to server on (default: %default)')
-    options, args = parser.parse_args()
-    if not args:
-        parser.error('You must supply a CONFIG_FILENAME')
-
-    from paste.urlparser import StaticURLParser
-    from paste.urlmap import URLMap
-    application = URLMap()
-    application['/static'] = StaticURLParser(os.path.join(os.path.dirname(__file__), 'static'))
-    application['/'] = Constance
-
-    import wsgiref.simple_server
-    server = wsgiref.simple_server.make_server('0.0.0.0', options.port, application)
-    server.base_environ['constance.config_filename'] = args[0]
-    server.serve_forever()
diff --git a/blog.py b/blog.py
@@ -0,0 +1,83 @@
+
+# vim:encoding=utf-8
+
+import os
+import re
+import email
+from datetime import datetime
+import genshi.template
+import lxml.etree
+
+import viewutils
+
+template_loader = genshi.template.TemplateLoader(
+        os.path.join(os.path.dirname(__file__), 'templates', 'blog'), 
+        variable_lookup='strict')
+
+def cleanup_metadata(header_items):
+    cleaned = {}
+    for k, v in header_items:
+        k = k.lower()
+        if k.endswith('date'):
+            v = datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
+        else:
+            v = v.decode('utf8') # XXX encoding
+        cleaned[k] = v
+    return cleaned
+
+class BlogEntry(object):
+
+    def __init__(self, dir, name):
+        content_filename = os.path.join(dir, name + '.txt')
+        self.id = name.decode('utf8')
+
+        # not really a MIME document, but parse it like one
+        msg = email.message_from_file(open(content_filename, 'r'))
+        self.metadata = cleanup_metadata(msg.items())
+        self.body = viewutils.markdown(msg.get_payload().decode('utf8'))
+        self.title = viewutils.mini_markdown(self.metadata['title'])
+
+        raw_tags = self.metadata.get('tags', '').strip()
+        if raw_tags:
+            self.tags = frozenset(tag.strip() for tag in raw_tags.split(','))
+        else:
+            self.tags = frozenset()
+
+        self.modified_date = datetime.fromtimestamp(os.path.getmtime(content_filename))
+        self.publication_date = self.metadata.get('publication-date', None) or self.modified_date
+        self.guid = self.metadata['guid']
+
+    def generate_atom(self):
+        return template_loader.load('entry.atom').generate(item=self)
+
+class BlogEntrySet(object):
+
+    def __init__(self, dir):
+        self.dir = dir
+        self.entries = []
+        for filename in os.listdir(dir):
+            m = re.match(r'([^.].*)\.txt$', filename)
+            if m:
+                self.entries.append(BlogEntry(dir, m.group(1)))
+    
+    def __iter__(self):
+        return iter(self.entries)
+
+def generate(dir, xslt):
+    entries = BlogEntrySet(dir)
+    
+    for entry in entries:
+        rendered = template_loader.load('entry.html').generate(item=entry).render('xhtml')
+        transformed = str(xslt(lxml.etree.fromstring(rendered)))
+        open(os.path.join(dir, entry.id.encode('utf8') + '.html'), 'w').write(transformed)
+    
+    # index
+    rendered = template_loader.load('index.html').generate(items=entries).render('xhtml')
+    transformed = str(xslt(lxml.etree.fromstring(rendered)))
+    open(os.path.join(dir, 'index.html'), 'w').write(transformed)
+
+    # feed
+    rendered = template_loader.load('index.atom').generate(items=entries).render('xml')
+    open(os.path.join(dir, 'index.atom'), 'w').write(rendered)
+
+    return entries
diff --git a/config.defaults b/config.defaults
@@ -1,61 +0,0 @@
-[global]
-
-# The name of the blog. Appears at the end of page <title>s.
-name = Untitled
-
-# The name of the author of this blog.
-author = Anonymous
-
-# The author's e-mail address. Leave this blank if you don't want to publish an 
-# e-mail address.
-email = 
-
-# The maximum number of items (of any kind) to be shown on each page.
-items_per_page = 20
-
-# Whether to include explicit previous/next <A> links for paged browsing (when needed). <LINK> elements are always included.
-show_prev_next = False
-
-# The maximum number of items to be included in feeds.
-items_in_feed = 20
-
-# Character encoding to be used everywhere. That is, for:
-#   * all data read from disk (including this config)
-#   * URL components and query string arguments
-#   * POST data
-#   * rendered templates
-# and anywhere else I have forgotten. Really whenever we are converting between 
-# Unicode data and bytestrings, this is the encoding that is used.
-# It is *highly* recommended that you not change this value from its default of 
-# utf8!
-encoding = utf8
-
-item_sets = [BlogEntrySet('./entries'), ReadingLogEntrySet('./reading_log.yaml')]
-
-[links]
-
-# The name of the YAML file containing links to be placed at the bottom of 
-# every page. Leaving this blank disables the link section.
-filename = 
-
-[blog]
-
-# Require reCAPTCHA verification for comment submission?
-require_captcha = False
-
-# If require_captcha is True, you must supply a valid public/private reCAPTCHA 
-# key pair here. Otherwise they can be left blank.
-# See https://admin.recaptcha.net/recaptcha/createsite/
-recaptcha_pubkey =
-recaptcha_privkey =
-
-[readinglog]
-
-# Should LibraryThing covers be shown for readinglog entries?
-# See also librarything_devkey below.
-show_covers = False
-
-# If show_covers is True, you must supply a valid LibraryThing developer key to 
-# be included in cover URLs. You can leave this blank otherwise.
-# See http://www.librarything.com/services/keys.php
-librarything_devkey = 
diff --git a/config.py b/config.py
@@ -1,15 +0,0 @@
-
-# vim:encoding=utf-8
-
-import os
-from ConfigParser import SafeConfigParser
-
-class ConstanceConfigParser(SafeConfigParser):
-
-    def __init__(self, filename):
-        SafeConfigParser.__init__(self)
-        self.readfp(open(os.path.join(os.path.dirname(__file__), 'config.defaults'), 'r'))
-        self.readfp(open(filename, 'r'))
-
-    def getunicode(self, section, option):
-        return self.get(section, option).decode(self.get('global', 'encoding'))
diff --git a/constance.py b/constance.py
@@ -0,0 +1,42 @@
+
+# vim:encoding=utf-8
+
+import os, sys
+sys.path.insert(0, os.path.dirname(__file__))
+sys.path.insert(1, os.path.join(os.path.dirname(__file__), 'lib'))
+
+import re
+import datetime
+import time
+import urllib
+import optparse
+import genshi.template
+import lxml.etree
+
+import blog
+import reading
+import tags
+import homepage
+
+def main():
+    parser = optparse.OptionParser()
+    parser.add_option('--blog-dir', metavar='DIR')
+    parser.add_option('--reading-log', metavar='FILENAME')
+    parser.add_option('--tags-dir', metavar='DIR')
+    parser.add_option('--root-dir', metavar='DIR')
+    parser.add_option('--xslt', metavar='FILENAME')
+    parser.set_defaults(blog_dir='./blog/',
+            reading_log='./reading_log.yaml',
+            tags_dir='./tags/',
+            root_dir='./',
+            xslt='./style.xsl')
+    options, args = parser.parse_args()
+
+    xslt = lxml.etree.XSLT(lxml.etree.parse(options.xslt))
+    blog_entries = blog.generate(options.blog_dir, xslt)
+    reading_entries = reading.generate(options.reading_log, xslt)
+    tags.generate(options.tags_dir, xslt, blog_entries)
+    homepage.generate(options.root_dir, xslt, blog_entries, reading_entries)
+
+if __name__ == '__main__':
+    main()
diff --git a/doc/deploy.txt b/doc/deploy.txt
@@ -1,15 +0,0 @@
-Deployment Instructions
-=======================
-
-Make a local copy of config.defaults (for example, config.local) and edit it 
-as appropriate.
-
-mod_wsgi
---------
-
-    SetEnv constance.config_filename /home/dan/constance/config.local
-    <Directory /home/dan/constance>
-        Allow from all
-    </Directory>
-    WSGIScriptAlias /blog /home/dan/constance/app.py
-    Alias /blog/static /home/dan/constance/static/
diff --git a/homepage.py b/homepage.py
@@ -0,0 +1,24 @@
+
+# vim:encoding=utf-8
+
+import os
+from itertools import chain
+import genshi.template
+import lxml.etree
+
+import viewutils
+
+template_loader = genshi.template.TemplateLoader(
+        os.path.join(os.path.dirname(__file__), 'templates', 'homepage'), 
+        variable_lookup='strict')
+
+def generate(dir, xslt, blog_entries, reading_entries):
+    # index
+    template = template_loader.load('index.html')
+    rendered = template.generate(blog_entries=blog_entries).render('xhtml')
+    transformed = str(xslt(lxml.etree.fromstring(rendered)))
+    open(os.path.join(dir, 'index.html'), 'w').write(transformed)
+
+    # firehose
+    rendered = template_loader.load('firehose.atom').generate(items=chain(blog_entries, reading_entries)).render('xml')
+    open(os.path.join(dir, 'firehose.atom'), 'w').write(rendered)
diff --git a/itemtypes.py b/itemtypes.py
@@ -1,231 +0,0 @@
-
-"""
-This module defines the various item types which are supported by Constance.
-
-An "item" is anything which can be referenced individually by a URL and 
-rendered, and/or appears in a stream of items. As a minimum each type of item 
-must have the following data attached to it:
-
-    * datetime of original publication (as the `publication_date` attribute)
-    * datetime at which the content of the item was last modified (as the 
-      `modified_date` attribute)
-    * unique URI path within the collection (as the `uri_path` attribute)
-
-Each item type defines a collection type whose name ends in Set (e.g. 
-BlogEntrySet). The collection can be iterated across using iter(), which yields 
-a sequence of item instances (if a collection does not support iteration its 
-iterator yields nothing). Individual items can also be fetched from the 
-collection by calling its `get` attribute with a URI path; either an item 
-instance is returned, or KeyError is raised if the URI path is not known.
-"""
-
-import os, re, uuid, email
-from datetime import datetime
-import genshi.template
-import yaml
-
-template_loader = genshi.template.TemplateLoader(
-        os.path.join(os.path.dirname(__file__), 'templates'), 
-        variable_lookup='strict', 
-        auto_reload=True)
-
-__all__ = ['NoAccessError', 'NotExistError', 'UnsupportedFormatError', 
-           'BlogEntrySet', 'ReadingLogEntrySet']
-
-def count(iterable):
-    count = 0
-    for _ in iterable:
-        count += 1
-    return count
-
-def cleanup_metadata(header_items):
-    cleaned = {}
-    for k, v in header_items:
-        k = k.lower()
-        if k.endswith('date'):
-            v = datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
-        else:
-            v = v.decode('utf8') # XXX encoding
-        cleaned[k] = v
-    return cleaned
-
-
-class NoAccessError(StandardError): pass
-
-class NotExistError(StandardError): pass
-
-class UnsupportedFormatError(StandardError): pass
-
-
-class BlogEntry(object):
-
-    def __init__(self, entries_dir, id, uri_path):
-        assert isinstance(id, str), id
-        self.id = id.decode('utf8') # XXX shouldn't hardcode the encoding
-        self.uri_path = uri_path
-        self.dir = os.path.join(entries_dir, id)
-        self.comments_dir = os.path.join(self.dir, 'comments')
-
-        if not os.path.exists(self.dir):
-            raise EntryNotFoundError()
-        if not os.access(self.dir, os.R_OK):
-            raise EntryForbiddenError()
-
-        # not really a MIME document, but parse it like one
-        msg = email.message_from_file(open(os.path.join(self.dir, 'content.txt'), 'r'))
-        self.metadata = cleanup_metadata(msg.items())
-        self.body = msg.get_payload().decode('utf8') # XXX encoding
-        self.title = self.metadata['title']
-
-        raw_tags = self.metadata.get('tags', '').strip()
-        if raw_tags:
-            self.tags = frozenset(tag.strip() for tag in raw_tags.split(','))
-        else:
-            self.tags = frozenset()
-
-        self.modified_date = datetime.fromtimestamp(os.path.getmtime(os.path.join(self.dir, 'content.txt')))
-        self.publication_date = self.metadata.get('publication-date', None) or self.modified_date
-        self.guid = self.metadata['guid']
-
-    def render(self, format):
-        if format == 'text/html':
-            template = template_loader.load('html/' + self.__class__.__name__ + '.xml')
-            return template.generate(item=self)
-        elif format == 'application/atom+xml':
-            template = template_loader.load('atom/' + self.__class__.__name__ + '.xml')
-            return template.generate(item=self)
-        else:
-            raise UnsupportedFormatError(format)
-
-    def comments(self):
-        return CommentSet(self.comments_dir)
-
-    def has_comments(self):
-        """
-        Returns True if this Entry could *possibly* have comments, although it 
-        may still have no comments (yet).
-        """
-        return os.path.isdir(self.comments_dir) and \
-                os.access(self.comments_dir, os.R_OK)
-
-    def add_comment(self, metadata, content):
-        if not os.access(self.comments_dir, os.W_OK):
-            raise CommentingForbiddenError()
-        # XXX write to temp file
-        guid = uuid.uuid4().get_hex()
-        f = open(os.path.join(self.comments_dir, guid), 'w')
-        for k, v in metadata.iteritems():
-            f.write('%s: %s\n' % (k, v.encode('utf8'))) # XXX encoding
-        f.write('\n')
-        f.write(content.encode('utf8')) # XXX encoding
-
-
-class BlogEntrySet(object):
-
-    def __init__(self, base_dir, prefix='/blog'):
-        self.base_dir = base_dir
-        assert os.path.isdir(self.base_dir), self.base_dir
-        self.prefix = prefix
-        self.index_patt = re.compile(re.escape(prefix) + r'/?(index)?$')
-        self.entry_patt = re.compile(re.escape(prefix) + r'/([^/]+)/?$')
-
-    def get(self, path_info):
-        if self.index_patt.match(path_info):
-            return iter(self)
-        m = self.entry_patt.match(path_info)
-        if m is None:
-            raise NotExistError(path_info)
-        id = m.group(1).encode('utf8') # XXX don't hardcode
-        if not os.path.isdir(os.path.join(self.base_dir, id)):
-            raise NotExistError(path_info)
-        return BlogEntry(self.base_dir, id, path_info)
-
-    def __iter__(self):
-        assert isinstance(self.base_dir, str)
-        return (BlogEntry(self.base_dir, filename, self.prefix + '/' + filename.decode('utf8'))
-                for filename in os.listdir(self.base_dir)
-                if not filename.startswith('.'))
-
-
-class ReadingLogEntry(object):
-
-    def __init__(self, yaml_dict):
-        self.title = yaml_dict['Title']
-        self.author = yaml_dict['Author']
-        self.publication_date = self.modified_date = self.date = yaml_dict['Date']
-        self.url = yaml_dict.get('URL', None)
-        self.isbn = yaml_dict.get('ISBN', None)
-        self.rating = yaml_dict.get('Rating', None)
-        self.tags = frozenset()
-        self.guid = yaml_dict['GUID']
-
-    def render(self, format):
-        if format == 'text/html':
-            template = template_loader.load('html/' + self.__class__.__name__ + '.xml')
-            return template.generate(item=self)
-        elif format == 'application/atom+xml':
-            template = template_loader.load('atom/' + self.__class__.__name__ + '.xml')
-            return template.generate(item=self)
-        else:
-            raise UnsupportedFormatError(format)
-
-    def has_comments(self):
-        return False
-
-
-class ReadingLogEntrySet(object):
-
-    def __init__(self, filename, prefix='/reading'):
-        self.filename = filename
-        assert os.path.isfile(self.filename), self.filename
-        self.prefix = prefix
-        self.index_patt = re.compile(re.escape(prefix) + r'/?(index)?$')
-
-    def get(self, path_info):
-        if self.index_patt.match(path_info):
-            return iter(self)
-        raise NotExistError(path_info)
-
-    def __iter__(self):
-        return (ReadingLogEntry(d)
-                for d in yaml.load_all(open(self.filename, 'r')))
-
-
-class Comment(object):
-
-    def __init__(self, comments_dir, id):
-        path = os.path.join(comments_dir, id)
-        if not os.path.exists(path):
-            raise CommentNotFoundError()
-        if not os.access(path, os.R_OK):
-            raise CommentForbiddenError()
-
-        self.id = id
-        msg = email.message_from_file(open(path, 'r'))
-        self.metadata = cleanup_metadata(msg.items())
-        self.body = msg.get_payload().decode('utf8') # XXX encoding
-        
-        self.author = self.metadata.get('from', None)
-        self.author_url = self.metadata.get('author-url', None)
-        self.date = datetime.fromtimestamp(os.path.getmtime(path))
-
-    def author_name(self):
-        return self.author or u'Anonymous'
-
-
-class CommentSet(object):
-
-    def __init__(self, base_dir):
-        self.base_dir = base_dir
-        assert os.path.isdir(self.base_dir), self.base_dir
-
-    def __len__(self):
-        return count(filename 
-                for filename in os.listdir(self.base_dir) 
-                if not filename.startswith('.'))
-
-    def __iter__(self):
-        assert isinstance(self.base_dir, str)
-        return (Comment(self.base_dir, filename)
-                for filename in os.listdir(self.base_dir)
-                if not filename.startswith('.'))
diff --git a/reading.py b/reading.py
@@ -0,0 +1,52 @@
+
+# vim:encoding=utf-8
+
+import os
+import genshi.template
+import yaml
+import lxml.etree
+
+import viewutils
+
+template_loader = genshi.template.TemplateLoader(
+        os.path.join(os.path.dirname(__file__), 'templates', 'reading'), 
+        variable_lookup='strict')
+
+class ReadingLogEntry(object):
+
+    def __init__(self, yaml_dict):
+        self.title = viewutils.mini_markdown(yaml_dict['Title'])
+        self.author = yaml_dict['Author']
+        self.publication_date = self.modified_date = self.date = yaml_dict['Date']
+        self.url = yaml_dict.get('URL', None)
+        self.isbn = yaml_dict.get('ISBN', None)
+        self.rating = yaml_dict.get('Rating', None)
+        self.tags = frozenset()
+        self.guid = yaml_dict['GUID']
+
+    def generate_atom(self):
+        return template_loader.load('entry.atom').generate(item=self)
+
+class ReadingLogEntrySet(object):
+
+    def __init__(self, filename):
+        self.filename = filename
+        self.entries = []
+        for d in yaml.load_all(open(self.filename, 'r')):
+            self.entries.append(ReadingLogEntry(d))
+
+    def __iter__(self):
+        return iter(self.entries)
+
+def generate(filename, xslt):
+    entries = ReadingLogEntrySet(filename)
+
+    rendered = template_loader.load('reading.html').generate(items=entries).render('xhtml')
+    transformed = str(xslt(lxml.etree.fromstring(rendered)))
+    open(os.path.join(os.path.dirname(filename), 'reading.html'), 'w').write(transformed)
+
+    # feed
+    rendered = template_loader.load('reading.atom').generate(items=entries).render('xml')
+    open(os.path.join(os.path.dirname(filename), 'reading.atom'), 'w').write(rendered)
+
+    return entries
diff --git a/static/css/common.css b/static/css/common.css
@@ -1,185 +0,0 @@
-
-body {
-    font-family: "Cambria", "Liberation Serif", serif;
-    line-height: 2.75ex;
-    margin: 0;
-    padding: 0;
-    background-color: #ddd;
-}
-#contentwrapper {
-    border-bottom: 1px dashed #aaa;
-    background-color: white;
-    margin: 0;
-    padding: 0.01em 1em 1em 2em;
-}
-#content {
-    margin: 1em auto;
-    max-width: 45em;
-}
-
-a {
-    color: #0066b3;
-}
-a:visited {
-    color: #990099;
-}
-h1, h2, h3, h4, h5, h6 {
-    font-family: "Corbel", "Liberation Sans", sans-serif;
-}
-pre, code {
-    font-family: "Consolas", monospace;
-}
-img {
-    border: none;
-}
-blockquote {
-    border-left: 3px solid #ddd;
-    padding-left: 1em;
-    margin-left: 2em;
-}
-pre {
-    margin-left: 2em;
-}
-hr {
-    border: none;
-    border-top: 1px dashed #aaa;
-    height: 0;
-}
-
-span.inlinesep {
-    font-weight: bold;
-    padding: 0 0.25em;
-}
-div.blocksep {
-    font-size: 1.6em;
-    text-align: center;
-}
-
-/* Styles which are common to all item types */
-.item {
-    margin-top: 2em;
-    padding-top: 2em;
-    clear: both;
-    border-top: 1px solid #ddd;
-}
-.item h3 {
-    display: inline;
-    margin-right: 0.5em;
-}
-.item .date {
-    font-family: "Corbel", "Liberation Sans", sans-serif;
-    display: inline;
-    font-size: 1.2em;
-    letter-spacing: 0.1ex;
-    word-spacing: 0.1em;
-    color: #888;
-    white-space: nowrap;
-    margin-right: 0.25em;
-}
-.item .date a {
-    color: #888;
-}
-.commentslink {
-    text-align: right;
-    font-style: italic;
-}
-.commentslink a {
-    color: #888;
-    white-space: nowrap;
-}
-
-/* Blog entries */
-.BlogEntry h3 {
-    font-size: 1.5em;
-    text-transform: uppercase;
-    letter-spacing: 0.1ex;
-    word-spacing: 0.2em;
-}
-.BlogEntry .tags {
-    display: inline;
-    font-style: italic;
-    color: #888;
-}
-.BlogEntry .tags a {
-    color: black;
-    white-space: nowrap;
-}
-
-/* Reading log entries */
-.ReadingLogEntry h3 {
-    font-size: 1.2em;
-    word-spacing: 0.1em;
-}
-.ReadingLogEntry h3 a {
-    font-style: italic;
-    margin-right: 0.2em;
-}
-.ReadingLogEntry .author {
-    white-space: nowrap;
-}
-.ReadingLogEntry img.cover {
-    float: right;
-    margin: 0 0 1em 1em;
-}
-.ReadingLogEntry .rating {
-    display: inline;
-    white-space: nowrap;
-}
-
-/* Comments */
-.commentblock {
-    margin: 2em 4em 0 4em;
-    font-size: 0.9em;
-}
-.commentblock h4 {
-    font-size: 1.2em;
-}
-.commentblock .commentmeta {
-    text-align: right;
-}
-.commentblock .commentmeta .commentdatetime {
-    color: #888;
-    margin: 0 0.5em;
-}
-.commentblock .commentmeta .permalink {
-    color: #888;
-}
-
-/* Comment form */
-.commentform label {
-    display: block;
-}
-.commentform input {
-    display: block;
-    width: 30em;
-}
-.commentform textarea {
-    width: 100%;
-    font-size: 1em;
-}
-
-/* Previous/next links, if enabled */
-#prevnextlinks {
-    padding: 2em 0;
-    clear: both;
-    border-top: 1px solid #ddd;
-    text-align: center;
-}
-
-/* Links at the bottom */
-#links {
-    padding: 0.5em 2em 2em 2em;
-    margin-top: 1em;
-}
-.linkrow {
-    text-align: center;
-    margin: 1em 0;
-}
-.linkrow h2 {
-    display: inline;
-    font-size: 1.1em;
-    margin-right: 1em;
-}
-.linkrow p {
-    display: inline;
-}
diff --git a/static/css/tag_cloud.css b/static/css/tag_cloud.css
@@ -1,19 +0,0 @@
-ol#tagcloud {
-    margin: 0;
-    padding: 0;
-    line-height: 4ex;
-}
-ol#tagcloud li {
-    display: inline;
-    list-style: none;
-    margin-right: 0.75em;
-}
-ol#tagcloud a {
-    white-space: nowrap;
-}
-ol#tagcloud .frequency {
-    /* http://css-discuss.incutio.com/?page=OffLeft */
-    position: absolute;
-    left: -1000px;
-    width: 100px;
-}
diff --git a/static/images/feed-icon-16x16.png b/static/images/feed-icon-16x16.png
Binary files differ.
diff --git a/static/images/star-half.png b/static/images/star-half.png
Binary files differ.
diff --git a/static/images/star-off.png b/static/images/star-off.png
Binary files differ.
diff --git a/static/images/star.png b/static/images/star.png
Binary files differ.
diff --git a/tags.py b/tags.py
@@ -0,0 +1,28 @@
+
+# vim:encoding=utf-8
+
+import os
+import genshi.template
+import lxml.etree
+
+import viewutils
+
+template_loader = genshi.template.TemplateLoader(
+        os.path.join(os.path.dirname(__file__), 'templates', 'tags'), 
+        variable_lookup='strict')
+
+def generate(dir, xslt, blog_entries):
+    tag_freqs = {}
+    for entry in blog_entries:
+        for tag in entry.tags:
+            tag_freqs[tag] = tag_freqs.get(tag, 0) + 1
+
+    for tag in tag_freqs.keys():
+        tagged_entries = [e for e in blog_entries if tag in e.tags]
+        rendered = template_loader.load('tag.html').generate(tag=tag, items=tagged_entries).render('xhtml')
+        transformed = str(xslt(lxml.etree.fromstring(rendered)))
+        open(os.path.join(dir, tag.encode('utf8') + '.html'), 'w').write(transformed)
+
+    rendered = template_loader.load('index.html').generate(tag_freqs=tag_freqs).render('xhtml')
+    transformed = str(xslt(lxml.etree.fromstring(rendered)))
+    open(os.path.join(dir, 'index.html'), 'w').write(transformed)
diff --git a/templates/atom/BlogEntry.xml b/templates/atom/BlogEntry.xml
@@ -1,27 +0,0 @@
-<entry xmlns="http://www.w3.org/2005/Atom"
-       xmlns:py="http://genshi.edgewall.org/"
-       xmlns:xi="http://www.w3.org/2001/XInclude">
-
-<?python
-import blog
-from viewutils import markdown, tag_list, ATOM_TIME_FORMAT
-?>
-
-<id>${item.guid}</id>
-<published>${item.publication_date.strftime(ATOM_TIME_FORMAT)}</published>
-<updated>${item.modified_date.strftime(ATOM_TIME_FORMAT)}</updated>
-<!--author py:with="email = config.getunicode('global', 'email')">
-    <name>${config.getunicode('global', 'author')}</name>
-    <email py:if="email">${email}</email>
-</author-->
-<category py:for="tag in item.tags" scheme="/tags/" term="${tag}" /><!-- XXX app_uri -->
-<link rel="alternate" href="${item.uri_path}" /><!-- XXX app_uri -->
-<title type="text">${item.title}</title>
-<content type="xhtml" xml:base="${item.uri_path}"><!-- XXX app_uri -->
-    <div xmlns="http://www.w3.org/1999/xhtml">
-        <p py:if="item.tags">Tagged: ${tag_list('', item.tags)}</p><!-- XXX script_name -->
-        ${markdown(item.body)}
-    </div>
-</content>
-
-</entry>
diff --git a/templates/atom/ReadingLogEntry.xml b/templates/atom/ReadingLogEntry.xml
@@ -1,26 +0,0 @@
-<entry xmlns="http://www.w3.org/2005/Atom"
-       xmlns:py="http://genshi.edgewall.org/"
-	   xmlns:xi="http://www.w3.org/2001/XInclude">
-
-<?python
-from viewutils import ATOM_TIME_FORMAT
-?>
-
-<id>${item.guid}</id>
-<published>${item.publication_date.strftime(ATOM_TIME_FORMAT)}</published>
-<updated>${item.modified_date.strftime(ATOM_TIME_FORMAT)}</updated>
-<!--author py:with="email = config.getunicode('global', 'email')">
-    <name>${config.getunicode('global', 'author')}</name>
-    <email py:if="email">${email}</email>
-</author-->
-<category py:for="tag in item.tags" scheme="/tags/" term="${tag}" /><!-- XXX app_uri -->
-<title type="text">${item.title} by ${item.author}</title>
-<summary py:if="item.rating" type="text">${item.rating} stars</summary>
-<content type="xhtml">
-    <div xmlns="http://www.w3.org/1999/xhtml">
-        <p><a href="${item.url}">${item.title}</a> by ${item.author}</p>
-        <p py:if="item.rating">${item.rating} stars</p>
-    </div>
-</content>
-
-</entry>
diff --git a/templates/atom/multiple.xml b/templates/atom/multiple.xml
@@ -1,21 +0,0 @@
-<feed xmlns="http://www.w3.org/2005/Atom"
-      xmlns:py="http://genshi.edgewall.org/"
-	  xmlns:xi="http://www.w3.org/2001/XInclude">
-
-<?python
-from viewutils import ATOM_TIME_FORMAT
-sorted_items = sorted(items, key=lambda item: item.publication_date, reverse=True)[:config.getint('global', 'items_in_feed')]
-?>
-
-<id>${self_url}</id>
-<title type="text">${config.getunicode('global', 'name')}<py:if test="title"> (${title})</py:if></title>
-<link rel="self" type="application/atom+xml" href="${self_url}" />
-<link rel="alternate" href="${self_url}" />
-<generator>constance</generator>
-<updated py:if="sorted_items">${max(item.modified_date for item in sorted_items).strftime(ATOM_TIME_FORMAT)}</updated>
-
-<py:for each="item in sorted_items">
-    ${item.render('application/atom+xml')}
-</py:for>
-
-</feed>
diff --git a/templates/blog/entry.atom b/templates/blog/entry.atom
@@ -0,0 +1,25 @@
+<entry xmlns="http://www.w3.org/2005/Atom"
+       xmlns:py="http://genshi.edgewall.org/"
+       xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<?python
+from viewutils import ATOM_TIME_FORMAT
+?>
+
+<id>${item.guid}</id>
+<published>${item.publication_date.strftime(ATOM_TIME_FORMAT)}</published>
+<updated>${item.modified_date.strftime(ATOM_TIME_FORMAT)}</updated>
+<author>
+    <name>Dan C</name>
+    <email>djc@djc.id.au</email>
+</author>
+<category py:for="tag in item.tags" scheme="http://www.djc.id.au/tags/" term="${tag}" />
+<link rel="alternate" href="http://www.djc.id.au/blog/${item.id}" />
+<title type="text">${item.title.striptags()}</title>
+<content type="xhtml" xml:base="http://www.djc.id.au/blog/${item.id}">
+    <div xmlns="http://www.w3.org/1999/xhtml">
+        ${item.body}
+    </div>
+</content>
+
+</entry>
diff --git a/templates/blog/entry.html b/templates/blog/entry.html
@@ -0,0 +1,30 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+	  lang="en-AU">
+
+<?python
+from viewutils import tag_list
+?>
+
+<head>
+    <title>${item.title.striptags()}</title>
+    <meta name="DC.date" content="${item.publication_date.strftime(str('%Y-%m-%d'))}" />
+</head>
+<body>
+    <div class="item blog-entry">
+
+        <h1 class="entry-title"><a href="http://www.djc.id.au/blog/${item.id}" rel="bookmark">${item.title}</a></h1>
+
+        <div class="date published">${item.publication_date.strftime(str('%-1d %b %Y'))}</div>
+
+        <div py:if="item.tags" class="tags">
+            tagged: ${tag_list('', item.tags)} <!-- XXX script_name -->
+        </div>
+
+        <div class="entry-content">
+            ${item.body}
+        </div>
+
+    </div>
+</body>
+</html>
diff --git a/templates/blog/index.atom b/templates/blog/index.atom
@@ -0,0 +1,21 @@
+<feed xmlns="http://www.w3.org/2005/Atom"
+      xmlns:py="http://genshi.edgewall.org/"
+	  xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<?python
+from viewutils import ATOM_TIME_FORMAT
+sorted_items = sorted(items, key=lambda item: item.publication_date, reverse=True)
+?>
+
+<id>http://www.djc.id.au/blog/index.atom</id>
+<title type="text">djc blog</title>
+<link rel="self" type="application/atom+xml" href="http://www.djc.id.au/blog/index.atom" />
+<link rel="alternate" href="http://www.djc.id.au/blog/" />
+<generator>constance</generator>
+<updated py:if="sorted_items">${max(item.modified_date for item in sorted_items).strftime(ATOM_TIME_FORMAT)}</updated>
+
+<py:for each="item in sorted_items">
+    ${item.generate_atom()}
+</py:for>
+
+</feed>
diff --git a/templates/blog/index.html b/templates/blog/index.html
@@ -0,0 +1,28 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+	  lang="en-AU">
+
+<?python
+from itertools import groupby
+from viewutils import markdown, mini_markdown, tag_list
+?>
+
+<head>
+    <title>Blog archive</title>
+    <link rel="alternate" type="application/atom+xml" title="Atom feed" href="index.atom" />
+</head>
+
+<body>
+
+    <h1>Blog archive</h1>
+
+    <py:for each="year, items in groupby(sorted(items, key=lambda e: e.publication_date, reverse=True), key=lambda e: e.publication_date.year)">
+        <h2>${year}</h2>
+        <div class="item blog-entry-stub" py:for="item in items">
+            <h3 class="entry-title"><a href="${item.id}">${item.title}</a></h3>
+            <div class="date published">${item.publication_date.strftime(str('%-1d %b %Y'))}</div>
+        </div>
+    </py:for>
+
+</body>
+</html>
diff --git a/templates/homepage/firehose.atom b/templates/homepage/firehose.atom
@@ -0,0 +1,21 @@
+<feed xmlns="http://www.w3.org/2005/Atom"
+      xmlns:py="http://genshi.edgewall.org/"
+	  xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<?python
+from viewutils import ATOM_TIME_FORMAT
+sorted_items = sorted(items, key=lambda item: item.publication_date, reverse=True)
+?>
+
+<id>http://www.djc.id.au/firehose.atom</id>
+<title type="text">djc firehose</title>
+<link rel="self" type="application/atom+xml" href="http://www.djc.id.au/firehose.atom" />
+<link rel="alternate" href="http://www.djc.id.au/" />
+<generator>constance</generator>
+<updated py:if="sorted_items">${max(item.modified_date for item in sorted_items).strftime(ATOM_TIME_FORMAT)}</updated>
+
+<py:for each="item in sorted_items">
+    ${item.generate_atom()}
+</py:for>
+
+</feed>
diff --git a/templates/homepage/index.html b/templates/homepage/index.html
@@ -0,0 +1,43 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+	  lang="en-AU">
+
+<?python
+from itertools import groupby
+from viewutils import markdown, mini_markdown, tag_list
+?>
+
+<head>
+    <link rel="alternate" type="application/atom+xml" title="Atom feed" href="index.atom" />
+</head>
+
+<body py:with="sorted_blog_entries=sorted(blog_entries, key=lambda e: e.publication_date, reverse=True)">
+
+    <div class="item blog-entry" py:with="item = sorted_blog_entries[0]">
+
+        <h1 class="entry-title"><a href="http://www.djc.id.au/blog/${item.id}" rel="bookmark">${item.title}</a></h1>
+
+        <div class="date published">${item.publication_date.strftime(str('%-1d %b %Y'))}</div>
+
+        <div py:if="item.tags" class="tags">
+            tagged: ${tag_list('', item.tags)} <!-- XXX script_name -->
+        </div>
+
+        <div class="entry-content">
+            ${item.body}
+        </div>
+
+    </div>
+
+    <div>
+        <h2>Previous blog entries</h2>
+        <div class="item blog-entry-stub" py:for="item in sorted_blog_entries[1:4]">
+            <h3 class="entry-title"><a href="blog/${item.id}">${item.title}</a></h3>
+            <div class="date published">${item.publication_date.strftime(str('%-1d %b %Y'))}</div>
+        </div>
+        <a href="blog/">more...</a>
+    </div>
+
+</body>
+</html>
+
diff --git a/templates/html/BlogEntry.xml b/templates/html/BlogEntry.xml
@@ -1,28 +0,0 @@
-<div xmlns="http://www.w3.org/1999/xhtml"
-     xmlns:py="http://genshi.edgewall.org/"
-     xmlns:xi="http://www.w3.org/2001/XInclude"
-     class="item BlogEntry">
-
-<xi:include href="_fragments.xml" />
-
-<?python
-from viewutils import markdown, mini_markdown, tag_list
-from recaptcha.client import captcha
-?>
-
-<h3 id="BlogEntry-${item.id}">${mini_markdown(item.title)}</h3>
-
-<div class="date">
-    ${item.publication_date.strftime(str('%-1d %b %Y'))}
-    <!-- XXX script_name --><a href="${item.uri_path}" rel="bookmark" class="permalink" title="permalink">#</a>
-</div>
-
-<div py:if="item.tags" class="tags">
-    tagged: ${tag_list('', item.tags)} <!-- XXX script_name -->
-</div>
-
-<div class="body">
-    ${markdown(item.body)}
-</div>
-
-</div>
diff --git a/templates/html/ReadingLogEntry.xml b/templates/html/ReadingLogEntry.xml
@@ -1,35 +0,0 @@
-<div xmlns="http://www.w3.org/1999/xhtml"
-     xmlns:py="http://genshi.edgewall.org/"
-     xmlns:xi="http://www.w3.org/2001/XInclude"
-     class="item ReadingLogEntry">
-
-<xi:include href="_fragments.xml" />
-
-<?python
-from viewutils import markdown, mini_markdown, tag_list, idify
-from recaptcha.client import captcha
-?>
-
-<span py:def="stars(rating)" py:strip="True">
-<!-- XXX script_name -->
-<img src="/static/images/star.png" alt="[star]" py:for="_ in range(int(rating))" /><img src="/static/images/star-half.png" alt="[half-star]" py:if="rating > int(rating)" /><img src="/static/images/star-off.png" alt="" py:for="_ in range(int(5 - rating))" />
-</span>
-
-<!-- XXX img py:if="config.getboolean('readinglog', 'show_covers') and entry.isbn" class="cover"
-     src="http://covers.librarything.com/devkey/${config.get('readinglog', 'librarything_devkey')}/small/isbn/${entry.isbn}" 
-     alt="Cover image for ${entry.title}" /-->
-
-<h3 id="ReadingLogEntry-${idify(item.title)}">
-    <a py:strip="not item.url" href="${item.url}">${mini_markdown(item.title)}</a>
-    <span py:if="item.author" class="author">by ${item.author}</span>
-</h3>
-
-<div class="date">
-    ${item.publication_date.strftime(str('%-1d %b %Y'))}
-</div>
-
-<div py:if="item.rating" class="rating">
-    ${stars(item.rating)}
-</div>
-
-</div>
diff --git a/templates/html/_commonwrapper.xml b/templates/html/_commonwrapper.xml
@@ -1,48 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<div xmlns="http://www.w3.org/1999/xhtml"
-     xmlns:py="http://genshi.edgewall.org/"
-     xmlns:xi="http://www.w3.org/2001/XInclude"
-     py:strip="True">
-
-<xi:include href="_fragments.xml" />
-
-<?python
-import yaml
-?>
-
-<py:match path="head">
-	<head profile="http://gmpg.org/xfn/11" py:attrs="select('@*')" py:with="title = unicode(select('title[1]/text()'))">
-		${select('./*[local-name() != "title"]')}
-        <title py:if="title">${title} - ${config.getunicode('global', 'name')}</title>
-		<title py:if="not title">${config.getunicode('global', 'name')}</title>
-        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
-		<meta name="generator" content="constance" />
-        <link rel="stylesheet" type="text/css" href="/static/css/common.css" /><!-- XXX script_name -->
-	</head>
-</py:match>
-
-<py:match path="body">
-	<body py:attrs="select('@*')">
-        <div id="contentwrapper">
-            <div id="content">
-                ${select('./*')}
-            </div>
-        </div>
-        <div id="links" 
-             py:if="config.getunicode('links', 'filename')"
-             py:with="link_sections = yaml.load(open(config.getunicode('links', 'filename'), 'r'))">
-            <div class="linkrow" py:for="section, links in link_sections.iteritems()">
-                <h2>${section}</h2>
-                <p>
-                    <py:for each="n, link in enumerate(links)">
-                        <py:if test="n &gt; 0">${inline_sep()}</py:if>
-                        <a py:attrs="dict((a, v) for a, v in link.iteritems() if a != 'anchor')">${link['anchor']}</a>
-                    </py:for>
-                </p>
-            </div>
-        </div>
-	</body>
-</py:match>
-
-</div>
-
diff --git a/templates/html/_fragments.xml b/templates/html/_fragments.xml
@@ -1,13 +0,0 @@
-<div xmlns="http://www.w3.org/1999/xhtml"
-     xmlns:py="http://genshi.edgewall.org/"
-     xmlns:xi="http://www.w3.org/2001/XInclude"
-     py:strip="True">
-
-<?python
-import urllib
-?>
-
-<span py:def="inline_sep()" class="inlinesep">·</span>
-<div py:def="block_sep()" class="blocksep">~</div>
-
-</div>
diff --git a/templates/html/multiple.xml b/templates/html/multiple.xml
@@ -1,46 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml"
-      xmlns:py="http://genshi.edgewall.org/"
-	  xmlns:xi="http://www.w3.org/2001/XInclude"
-	  lang="en-AU">
-<xi:include href="_commonwrapper.xml" />
-
-<?python
-sorted_items = sorted(items, key=lambda item: item.publication_date, reverse=True)
-?>
-
-<head>
-	<title py:if="title">${title}</title>
-    <link rel="alternate" type="application/atom+xml" title="Atom feed" href="?format=atom" />
-    <py:if test="defined('offset')">
-        <link py:if="bool(offset)" rel="prev" href="?offset=${max(0, offset - 20)}" />
-        <link py:if="len(sorted_items) > offset + config.getint('global', 'items_per_page')" rel="next" href="?offset=${offset + 20}" />
-    </py:if>
-</head>
-<body>
-
-<h2 py:if="title">Archive of ${title}</h2>
-
-<py:for each="item in (defined('offset') and sorted_items[offset:offset + config.getint('global', 'items_per_page')] or sorted_items)">
-    ${item.render('text/html')}
-
-    <div class="commentslink" py:if="item.has_comments()">
-        <!-- XXX script_name --><a href="${item.uri_path}#comments" py:choose="len(item.comments())">
-            <py:when test="0">no comments »</py:when>
-            <py:when test="1">1 comment »</py:when>
-            <py:otherwise>${len(item.comments())} comments »</py:otherwise>
-        </a>
-    </div>
-</py:for>
-
-<p id="prevnextlinks"
-   py:if="defined('offset') and config.getboolean('global', 'show_prev_next')"
-   py:with="show_prev = bool(offset);
-            show_next = len(sorted_items) > offset + config.getint('global', 'items_per_page')">
-    <a py:if="show_prev" rel="prev" href="?offset=${max(0, offset - 20)}">Newer items</a>
-    <py:if test="show_prev and show_next">—</py:if>
-    <a py:if="show_next" rel="next" href="?offset=${offset + 20}">Older items</a>
-</p>
-
-</body>
-</html>
diff --git a/templates/html/single.xml b/templates/html/single.xml
@@ -1,70 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml"
-      xmlns:py="http://genshi.edgewall.org/"
-	  xmlns:xi="http://www.w3.org/2001/XInclude"
-	  lang="en-AU">
-<xi:include href="_commonwrapper.xml" />
-
-<?python
-from viewutils import markdown
-from recaptcha.client import captcha
-?>
-
-<head>
-  <title>${item.title}</title>
-</head>
-<body>
-
-${item.render('text/html')}
-
-<div id="comments" class="commentblock" py:if="item.has_comments()"
-     py:with="comments = sorted(item.comments(), key=lambda c: c.date)">
-
-    <h4 class="comments" py:choose="len(comments)">
-        <py:when test="0">No comments</py:when>
-        <py:when test="1">1 comment</py:when>
-        <py:otherwise>${len(entry.comments())} comments</py:otherwise>
-    </h4>
-    <div py:for="n, comment in enumerate(comments)" id="comment-${comment.id}">
-        ${markdown(comment.body, safe_mode='escape')}
-        <p class="commentmeta">
-
-            ― <a py:strip="not comment.author_url" href="${comment.author_url}">${comment.author_name()}</a>
-            <span class="commentdatetime">${comment.date.strftime(str('%-1d %b %Y %H:%M'))}</span>
-            <a href="#comment-${comment.id}" class="permalink" title="permalink">#</a>
-        </p>
-        ${block_sep()}
-    </div>
-    <div class="commentform"><form method="post" action="${item.uri_path}/comments/+new"><!-- XXX script_name -->
-
-        <p>
-            <label for="commentform-from">Name</label>
-            <input type="text" id="commentform-from" name="from" />
-        </p>
-        <p>
-            <label for="commentform-author-email">E-mail address (not published)</label>
-            <input type="text" id="commentform-author-email" name="author-email" />
-        </p>
-
-        <p>
-            <label for="commentform-author-url">URL</label>
-            <input type="text" id="commentform-author-url" name="author-url" />
-        </p>
-        <p>
-            <label for="commentform-comment">Comment (use <a href="http://daringfireball.net/projects/markdown/syntax">Markdown</a>, no HTML)</label>
-            <textarea id="commentform-comment" name="comment" rows="7" cols="30"></textarea>
-
-        </p>
-        <py:if test="config.getboolean('blog', 'require_captcha')">
-            <script type="text/javascript">
-                RecaptchaOptions = {theme: 'white'};
-            </script>
-            ${Markup(captcha.displayhtml(config.get('blog', 'recaptcha_pubkey')))}
-        </py:if>
-        <p><button type="submit">Submit</button></p>
-    </form></div>
-
-</div>
-
-</body>
-</html>
diff --git a/templates/html/tag_cloud.xml b/templates/html/tag_cloud.xml
@@ -1,24 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml"
-      xmlns:py="http://genshi.edgewall.org/"
-      xmlns:xi="http://www.w3.org/2001/XInclude"
-      lang="en-AU">
-<xi:include href="_commonwrapper.xml" />
-
-<head>
-    <title>Tag cloud</title>
-    <link rel="stylesheet" type="text/css" href="/static/css/tag_cloud.css" /><!-- XXX script_name -->
-</head>
-<body>
-
-<h2>Tag cloud</h2>
-
-<ol id="tagcloud">
-    <li py:for="tag, freq in sorted(tag_freqs.iteritems(), key=lambda (t, f): t.lower())">
-        <!-- XXX script_name --><a rel="tag" href="/tags/${tag}" style="font-size: ${0.8 + (freq / 10.)}em;">${tag}</a>
-        <span class="frequency">(used ${freq} times)</span>
-    </li>
-</ol>
-
-</body>
-</html>
diff --git a/templates/reading/entry.atom b/templates/reading/entry.atom
@@ -0,0 +1,26 @@
+<entry xmlns="http://www.w3.org/2005/Atom"
+       xmlns:py="http://genshi.edgewall.org/"
+	   xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<?python
+from viewutils import ATOM_TIME_FORMAT
+?>
+
+<id>${item.guid}</id>
+<published>${item.publication_date.strftime(ATOM_TIME_FORMAT)}</published>
+<updated>${item.modified_date.strftime(ATOM_TIME_FORMAT)}</updated>
+<author>
+    <name>Dan C</name>
+    <email>djc@djc.id.au</email>
+</author>
+<category py:for="tag in item.tags" scheme="http://www.djc.id.au/tags/" term="${tag}" />
+<title type="text">${item.title.striptags()} by ${item.author}</title>
+<summary py:if="item.rating" type="text">${item.rating} stars</summary>
+<content type="xhtml">
+    <div xmlns="http://www.w3.org/1999/xhtml">
+        <p><a href="${item.url}">${item.title}</a> by ${item.author}</p>
+        <p py:if="item.rating">${item.rating} stars</p>
+    </div>
+</content>
+
+</entry>
diff --git a/templates/reading/reading.atom b/templates/reading/reading.atom
@@ -0,0 +1,21 @@
+<feed xmlns="http://www.w3.org/2005/Atom"
+      xmlns:py="http://genshi.edgewall.org/"
+	  xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<?python
+from viewutils import ATOM_TIME_FORMAT
+sorted_items = sorted(items, key=lambda item: item.publication_date, reverse=True)
+?>
+
+<id>http://www.djc.id.au/reading.atom</id>
+<title type="text">djc reading log</title>
+<link rel="self" type="application/atom+xml" href="http://www.djc.id.au/reading.atom" />
+<link rel="alternate" href="http://www.djc.id.au/reading" />
+<generator>constance</generator>
+<updated py:if="sorted_items">${max(item.modified_date for item in sorted_items).strftime(ATOM_TIME_FORMAT)}</updated>
+
+<py:for each="item in sorted_items">
+    ${item.generate_atom()}
+</py:for>
+
+</feed>
diff --git a/templates/reading/reading.html b/templates/reading/reading.html
@@ -0,0 +1,47 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+	  lang="en-AU">
+
+<?python
+from viewutils import markdown, mini_markdown, tag_list, idify
+?>
+
+<span py:def="stars(rating)" py:strip="True">
+    <img src="/images/star.png" alt="[star]" py:for="_ in range(int(rating))" /><img src="/images/star-half.png" alt="[half-star]" py:if="rating > int(rating)" /><img src="/images/star-off.png" alt="" py:for="_ in range(int(5 - rating))" />
+</span>
+
+<head>
+    <title>Reading log</title>
+    <link rel="alternate" type="application/atom+xml" title="Atom feed" href="reading.atom" />
+</head>
+
+<body>
+
+    <h1>Reading log</h1>
+
+    <py:for each="item in sorted(items, key=lambda e: e.publication_date, reverse=True)">
+        <div class="item reading-log-entry">
+
+            <img py:if="item.isbn" class="cover"
+                 src="http://covers.librarything.com/devkey/f6da4b15120267233430bb13cdaf1be9/small/isbn/${item.isbn}" 
+                 alt="Cover image for ${item.title.striptags()}" />
+
+            <h3 id="${idify(item.title.striptags())}">
+                <a py:strip="not item.url" href="${item.url}">${item.title}</a>
+                <span py:if="item.author" class="author">by ${item.author}</span>
+            </h3>
+
+            <div class="date published">
+                ${item.publication_date.strftime(str('%-1d %b %Y'))}
+            </div>
+
+            <div py:if="item.rating" class="rating">
+                ${stars(item.rating)}
+            </div>
+
+        </div>
+    </py:for>
+
+</body>
+</html>
+
diff --git a/templates/tags/index.html b/templates/tags/index.html
@@ -0,0 +1,21 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+	  lang="en-AU">
+
+<head>
+    <title>Tag cloud</title>
+    <link rel="stylesheet" type="text/css" href="../style/tag_cloud.css" />
+</head>
+<body>
+
+<h1>Tag cloud</h1>
+
+<ol id="tagcloud">
+    <li py:for="tag, freq in sorted(tag_freqs.iteritems(), key=lambda (t, f): t.lower())">
+        <!-- XXX script_name --><a rel="tag" href="/tags/${tag}" style="font-size: ${0.8 + (freq / 10.)}em;">${tag}</a>
+        <span class="frequency">(used ${freq} times)</span>
+    </li>
+</ol>
+
+</body>
+</html>
diff --git a/templates/tags/tag.html b/templates/tags/tag.html
@@ -0,0 +1,24 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+	  lang="en-AU">
+
+<?python
+from itertools import groupby
+from viewutils import markdown, mini_markdown, tag_list
+?>
+
+<head>
+    <title>“${tag}” tag</title>
+</head>
+
+<body>
+
+    <h1>“${tag}” tag</h1>
+
+    <div class="item blog-entry-stub" py:for="item in sorted(items, key=lambda e: e.publication_date, reverse=True)">
+        <h3 class="entry-title"><a href="${item.id}">${item.title}</a></h3>
+        <div class="date published">${item.publication_date.strftime(str('%-1d %b %Y'))}</div>
+    </div>
+
+</body>
+</html>