merge in pages changes on old www.qmsk.net
authorTero Marttila <terom@paivola.fi>
Sun, 14 Sep 2014 13:05:35 +0300
changeset 223 6a091bbffffd
parent 219 b83d32d54b48 (diff)
parent 222 2a078a823a95 (current diff)
child 224 cea4578f7910
merge in pages changes on old www.qmsk.net
.hgignore
qmsk_www/static/style.css
--- a/.hgignore	Sun Sep 14 12:55:49 2014 +0300
+++ b/.hgignore	Sun Sep 14 13:05:35 2014 +0300
@@ -1,5 +1,7 @@
-syntax: regexp
-\.[^/]+.sw[op]$
-\.pyc$
-^cache/templates/.
-^stuff/
+syntax: glob
+
+.*.swo
+.*.swp
+*.pyc
+
+opt/
--- a/__init__.py	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-"""
-    The www.qmsk.net site is just a simple site with a filesystem-based URL mapping
-"""
-
--- a/index.cgi	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,49 +0,0 @@
-#!/usr/bin/python
-# :set filetype=py encoding=utf8
-
-"""
-    CGI implementation
-"""
-
-# CGI handler for WSGI
-import wsgiref.handlers
-
-def cgi_error () :
-    """
-        Dumps out a raw traceback of the current exception to stdout, intended for use from except
-    """
-
-    import traceback, sys
-
-    print 'Status: 500 Internal Server Error\r'
-    print 'Content-type: text/plain\r'
-    print '\r'
-
-    traceback.print_exc(100, sys.stdout)
-
-def cgi_main () :
-    """
-        Run in CGI mode
-    """
-
-    try :
-        from qmsk.web import wsgi, template
-        import lookup
-
-        # create handler
-        cgi_handler = wsgiref.handlers.CGIHandler()
-
-        # create app handler
-        handler = lookup.PageMapper("/var/www/qmsk.net/pages", templates=template.TemplateLoader("/var/www/qmsk.net/templates", cache_dir="/var/www/qmsk.net/cache/templates"))
-
-        # create app
-        app = wsgi.Application(handler)
-        
-        # run once
-        cgi_handler.run(app)
-
-    except :
-        cgi_error()
-    
-if __name__ == '__main__' :
-    cgi_main()
--- a/lookup.py	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,120 +0,0 @@
-
-import os, os.path
-
-from qmsk.web import http, template, handler
-
-import page, page_tree
-
-class PageMapper (handler.RequestHandler) :
-    """
-        Translates requests to handlers based on a filesystem directory containing various kinds of files
-    """
-
-    # list of page handlers, by type
-    PAGE_TYPES = [
-        ('html',                    page.HTMLPage           ),
-        (template.TEMPLATE_EXT,     page.TemplatePage       ),
-    ]
-
-    def __init__ (self, path, templates) :
-        """
-            Create, path is where the pages are stored. The list of pages is loaded from $path/list
-        """
-        
-        # store
-        self.path = path 
-        self.templates = templates
-
-        # load the page tree
-        self.tree = page_tree.PageTree(path + '/list')
-    
-    def _lookup_page_type (self, url, path, filename, basename, extension, tail) :
-        """
-            We found the file that we looked for, now get the correct type
-        """
-
-        # find appropriate handler
-        for handler_ext, type in self.PAGE_TYPES :
-            # match against file extension?
-            if handler_ext == extension :
-                # found handler, return instance
-                return type(self, url, path, basename, tail)
-
-        # no handler found
-        raise PageError("No handler found for page %r of type %r" % (url, extension))
-
-    def _lookup_page (self, name) :
-        """
-            Look up and return a Page object for the given page, or raise an error
-        """
-
-        # inital path
-        path = self.path
-        url_segments = []
-
-        # name segments
-        segments = name.split('/')
-
-        # iterate through the parts of the page segments
-        while True :
-            segment = None
-
-            # pop segment
-            if segments :
-                segment = segments.pop(0)
-
-                url_segments.append(segment)
-
-            # translate empty -> index
-            if not segment :
-                segment = 'index'
-
-            # look for it in the dir
-            for filename in os.listdir(path) :
-                # build full file path
-                file_path = os.path.join(path, filename)
-
-                # stat, recurse into subdirectory?
-                if os.path.isdir(file_path) and filename == segment :
-                    # use new dir
-                    path = file_path
-
-                    # break for-loop to look at next segment
-                    break
-     
-                # split into basename + extension
-                basename, extension = os.path.splitext(filename)
-
-                # ...remove that dot
-                extension = extension.lstrip('.')
-                
-                # match against requested page name?
-                if basename == segment :
-                    # found the file we wanted
-                    return self._lookup_page_type('/'.join(url_segments), file_path, filename, basename, extension, '/'.join(segments))
-                
-                else :
-                    # inspect next file in dir
-                    continue
-
-            else :
-                # did not find any dir or file, break out of while loop
-                break
-
-        # did not find the filename we were looking for in os.listdir
-        raise NameError(name)
-
-    def handle_request (self, request) :
-        """
-            Looks up the appropriate Page, and then renders it
-        """
-
-        # determine the page name
-        page_name = request.get_page_name()
-
-        # get the page handler
-        page = self._lookup_page(page_name)
-        
-        # pass on
-        return page.handle_request(request)
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/manage.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,10 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "qmsk_www.settings")
+
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line(sys.argv)
--- a/menu.py	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-"""
-    Handling the list of available pages
-"""
-
-class Menu (object) :
-    """
-        Contains info needed to render the menu
-    """
-
-    def __init__ (self, fs, page) :
-        """
-            Gather the menu information for the given page, as part of the given FilesystemMapper
-        """
-
-        # the selected page
-        self.page = fs.tree.get_page(page.url)
-
-        # the selected pagen's inheritance
-        self.ancestry = self.page.get_ancestry() if self.page else []
-        
-        # list of menu items == root children, since we always show the full menu...
-        self.items = fs.tree.root.children
-    
--- a/page.py	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,135 +0,0 @@
-
-"""
-    Handling page requests
-"""
-
-# for filesystem ops
-import os, os.path
-import time
-
-from qmsk.web import http, handler, config
-
-import menu
-
-class PageError (http.ResponseError) :
-    """
-        Error looking up/handling a page
-    """
-
-    pass
-
-# XXX: should inherit from PageInfo
-class Page (handler.RequestHandler) :
-    """
-        This object represents the information about our attempt to render some specific page
-    """
-
-    def __init__ (self, fs, url, path, basename, url_tail, charset='utf8') :
-        """
-            Initialize the page at the given location
-            
-            @param fs the FilesysteMapper
-            @param url the URL leading to this page
-            @param path the filesystem path to this page's file
-            @param basename the filesystem name of this page's file, without the file extension
-            @param url_trail trailing URL for this page
-            @param charset file charset
-        """
-        
-        # store
-        self.fs = fs
-        self.url = url
-        self.path = path
-        self.basename = basename
-        self.url_tail = url_tail
-        self.charset = charset
-
-        # sub-init
-        self._init()
-
-    def _init (self) :
-        """
-            Do initial data loading, etc
-        """
-        
-        pass
-
-    @property
-    def title (self) :
-        """
-            Return the page's title
-
-            Defaults to the retreiving the page title from page_list, or basename in Titlecase.
-        """
-        
-        # lookup in PageTree
-        page_info = self.fs.tree.get_page(self.url)
-        
-        # fallback to titlecase
-        if page_info :
-            title = page_info.title
-
-        else :
-            title = self.basename.title()
-
-        return title
-    
-    @property
-    def content (self) :
-        """
-            Return the page content as a string
-        """
-
-        abstract
-    
-    @property
-    def modified (self) :
-        """
-            Returns the page modification timestamp
-        """
-        
-        # stat
-        timestamp = os.stat(self.path).st_mtime
-
-        return time.strftime(config.DATETIME_FMT, time.gmtime(timestamp))
-    
-    def handle_request (self, request) :
-        """
-            Renders the fs's layout template with this page + menu
-        """
-
-        # render the template
-        return self.fs.templates.render_to_response("layout",
-            req             = request,
-            page            = self,
-            menu            = menu.Menu(self.fs, self),
-        )
-
-class HTMLPage (Page) :
-    """
-        A simple .html page that's just passed through directly
-    """
-
-    @property
-    def content (self) :
-        """
-            Opens the .html file, reads and returns contents
-        """
-
-        return open(self.path, 'rb').read().decode(self.charset)
-
-class TemplatePage (Page) :
-    """
-        A template that's rendered using our template library
-    """
-    
-    @property
-    def content (self) :
-        """
-            Loads the .tmpl file, and renders it
-        """
-
-        return self.fs.templates.render_file(self.path,
-            page_tree   = self.fs.tree,
-        )
-
--- a/page_tree.py	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,218 +0,0 @@
-"""
-    Implements the tree containing pages and their metadata
-"""
-
-from qmsk.web import tree_parse
-
-class PageTreeError (Exception) :
-    """
-        Error parsing/loading the page tree
-    """
-
-    pass
-
-class PageInfo (object) :
-    """
-        Contains metainformation about a page
-    """
-
-    def __init__ (self, parent, name, title, children=None) :
-        """
-            Initialize, children defaults to empty list
-        """
-
-        # store
-        self.parent = parent
-        self.name = name
-        self.title = title
-        self.children = children if children else []
-
-        # no url get
-        self._url = None
-    
-    def set_parent (self, parent) :
-        """
-            Set a parent where non was set before
-        """
-
-        assert self.parent is None
-
-        self.parent = parent
-
-    def add_child (self, child) :
-        """
-            Add a PageInfo child
-        """
-
-        self.children.append(child)
-    
-    def get_child (self, name) :
-        """
-            Look up a child by name, returning None if not found
-        """
-
-        return dict((c.name, c) for c in self.children).get(name)
-
-    def get_ancestry (self) :
-        """
-            Returns a list of this page's parents and the page itself, but not root
-        """
-        
-        # collect in reverse order
-        ancestry = []
-        
-        # starting from self
-        item = self
-        
-        # add all items, but not root
-        while item and item.parent :
-            ancestry.append(item)
-
-            item = item.parent
-
-        # reverse
-        ancestry.reverse()
-        
-        # done
-        return ancestry
-
-    @property
-    def url (self) :
-        """
-            Build this page's URL
-        """
-
-        # cached?
-        if self._url :
-            return self._url
-
-        segments = [item.name for item in self.get_ancestry()]
-        
-        # add empty segment if dir
-        if self.children :
-            segments.append('')
-        
-        # join
-        url = '/'.join(segments)
-        
-        # cache
-        self._url = url
-        
-        # done
-        return url
-
-class PageTree (object) :
-    """
-        The tree of pages, rooted at .root.
-
-        Use load_page_tree to initialize the global page_tree instance, and then use that
-    """
-
-    def __init__ (self, path) :
-        """
-            Loads the PageTree root from the given file
-        """
-        
-        # store
-        self.path = path
-        
-        # load
-        self._load(path)
-
-    def _load (self, path) :
-        """
-            Processes the lines in the given file
-        """
-        
-        # parse tree
-        tree = tree_parse.parse(path, ':')
-
-        if not tree :
-            raise PageTreeError("No root node found")
-
-        def _create_node (parent, item) :
-            """
-                Creates and returns a PageInfo from the given parent node and (line_number, line, children) tuple item
-            """
-
-            # unpack
-            line_number, line, children = item
-            
-            # parse line
-            url = title = None
-            
-            try :
-                url, title = line.split(':')
-
-            except :
-                raise PageTreeError("Invalid line: %s:%d: %r" % (path, line_number, line))
-
-            # remove whitespace
-            url = url.strip()
-            title = title.strip()
-            
-            # create PageInfo
-            node = PageInfo(parent, url, title)
-            
-            # set node children
-            node.children = [
-                _create_node(node, child_item) for child_item in children
-            ]
-
-            # return
-            return node
-        
-        # translate
-        self.root = _create_node(None, tree)
-            
-        # *evil cackle*
-        self.root.children.insert(0, self.root)
-        
-    def get_page (self, url) :
-        """
-            Lookup the given page URL, and return the matching PageInfo object, or None, if not found
-        """
-        
-        # start from root
-        node = self.root
-        
-        # traverse the object tree
-        for segment in url.split('/') :
-            if segment :
-                node = node.get_child(segment)
-
-            if not node :
-                return None
-        
-        # return
-        return node
-    
-    def get_siblings (self, url) :
-        """
-            Get the list of siblings for the given url, including the given page itself
-        """
-        
-        # look up the page itself
-        page = self.get_page(url)
-        
-        # specialcase root/unknown node
-        if page and page.parent :
-            return page.parent.children
-
-        else :
-            return self.root.children
-    
-    def dump (self) :
-        """
-            Returns a string representation of the tree
-        """
-        
-        def _print_node (indent, node) :
-            return '\n'.join('%s%s' % (' '*indent, line) for line in [
-                "%-15s : %s" % (node.name, node.title)
-            ] + [
-                _print_node(indent + 4, child) for child in node.children if child != node
-            ])
-
-        return _print_node(0, self.root)
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/settings/__init__.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,19 @@
+"""
+    Django settings for qmsk_www project.
+
+    For more information on this file, see
+    https://docs.djangoproject.com/en/1.7/topics/settings/
+
+    For the full list of settings and their values, see
+    https://docs.djangoproject.com/en/1.7/ref/settings/
+"""
+
+from qmsk_www.settings.database import *
+from qmsk_www.settings.localization import *
+from qmsk_www.settings.pages import *
+from qmsk_www.settings.site import *
+
+try:
+    from qmsk_www.settings.production import *
+except ImportError:
+    from qmsk_www.settings.development import *
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/settings/database.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,6 @@
+## Database
+# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
+DATABASES = {
+
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/settings/development.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,37 @@
+# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = '&e#@fir!k-*w(=0$@j&0guyv8qq*mh7_55j6w@2=hm0x9^ya-2'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+TEMPLATE_DEBUG = True
+
+ALLOWED_HOSTS = []
+
+## Logging
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'formatters': {
+        'qmsk': {
+            'format': '[%(levelname)5s] %(module)20s:%(funcName)-20s : %(message)s',
+        },
+    },
+    'handlers': {
+        'console': {
+            'level':        'DEBUG',
+            'class':        'logging.StreamHandler',
+            'formatter':    'qmsk',
+        },
+    },
+    'loggers': {
+        'qmsk_www_pages': {
+            'handlers': [ 'console' ],
+            'level': 'INFO',
+        }
+    },
+}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/settings/localization.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,13 @@
+## Internationalization
+# https://docs.djangoproject.com/en/1.7/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/settings/pages.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,2 @@
+QMSK_WWW_PAGES_DIR = './pages'
+QMSK_WWW_PAGES_SITE = "qmsk.net"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/settings/site.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,27 @@
+## Application definition
+INSTALLED_APPS = (
+    'django.contrib.staticfiles',
+    
+    'qmsk_www_pages',
+)
+
+MIDDLEWARE_CLASSES = (
+
+)
+
+ROOT_URLCONF = 'qmsk_www.urls'
+
+WSGI_APPLICATION = 'qmsk_www.wsgi.application'
+
+## Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.7/howto/static-files/
+STATIC_URL = '/static/'
+STATICFILES_DIRS = (
+    './qmsk_www/static',
+)
+
+## Site templates
+TEMPLATE_DIRS = (
+    './qmsk_www/templates',
+)
+
Binary file qmsk_www/static/link.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/static/style.css	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,172 @@
+
+/*
+ * Three main areas:
+ *  header
+ *  nav
+ *  content
+ *
+ * Border layout:
+ *      Header (bottom)
+ *      Content (left)
+ */
+
+/*
+ * General
+ */
+a:hover {
+    color: inherit;
+}
+
+code {
+    color: inherit;
+    background-color: inherit;
+}
+
+/*
+ * Top header
+ */
+div#header {
+    padding: 30px;
+    
+    border-bottom: 1px dashed #a5a5a5;
+}
+
+div#header h1 {
+    font-size: 48pt;
+    font-weight: bold;
+}
+
+div#header a:hover {
+    text-decoration: none;
+}
+
+/*
+ * Main navigation menu
+ */
+#nav {
+    margin: 20px 0px;
+}
+
+#nav ul {
+
+}
+
+#nav ul ul {
+
+}
+
+#nav li a {
+
+}
+
+#nav li a:hover {
+    background-color: #d0d0d0;
+    text-decoration: none;
+}
+
+#nav li.page-tree-active {
+    background-color: #808080;
+}
+
+/*
+ * Content
+ */
+div#content {
+
+}
+
+div#breadcrumb {
+    display: none;
+    font-size: x-small;
+}
+
+/*
+ * Footer
+ */
+div#footer {
+    padding: 20px 0px;
+
+    border-top: 1px dashed #a5a5a5;
+
+    font-size: x-small;
+    font-style: italic;
+}
+
+div#footer-left {
+    float: left;
+}
+
+.page-footer-modified {
+    float: right;
+    text-align: right;
+}
+
+div#footer-center {
+    text-align: center;
+}
+
+/*
+ * General styles
+ */
+a {
+    color: black;
+    text-decoration: none;
+    font-weight: bold;
+}
+
+a:hover {
+    text-decoration: underline;
+}
+
+h1 {
+    font-size: xx-large;
+
+    text-align: center;
+}
+
+h2 {
+    font-size: large;
+    
+    margin-left: 0px;
+    padding: 5px;
+    width: 100%;
+
+    background-color: #e5e5e5;
+    
+    border: 1px dashed #c5c5c5;
+}
+
+h3 {
+    font-size: medium;
+    font-style: italic;
+}
+
+#content p {
+    margin-left: 0.5em;
+}
+
+#content li {
+    padding: 2px;
+}
+
+#content a {
+    padding-right: 13px;
+    
+    background: transparent url(/static/link.png) no-repeat center right;
+}
+
+dt {
+    font-size: large;
+}
+
+dd {
+    margin: 1em auto 1em 5em;
+}
+
+code {
+    display: block;
+    margin: 8px;
+    padding: 8px;
+
+    border: 1px dotted #b5b5b5;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/templates/site.html	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,25 @@
+{% load staticfiles %}
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <title>{% block title %}{% endblock %}</title>
+
+        <!-- Bootstrap -->
+        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
+        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
+
+        <!-- qmsk.www.pages -->
+        <link rel="stylesheet" href="{% static "style.css" %}">
+        <link rel="stylesheet" href="{% static "pages/pages.css" %}">
+    </head>
+    <body>
+        {% block content %}{% endblock %}
+
+        <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
+        <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
+
+        <!-- Include all compiled plugins (below), or include individual files as needed -->
+        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
+    </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/urls.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,15 @@
+from django.conf.urls import patterns, include, url
+
+urlpatterns = patterns('',
+    # Examples:
+    # url(r'^$', 'qmsk_www.views.home', name='home'),
+    # url(r'^blog/', include('blog.urls')),
+    url(r'^',           include('qmsk_www_pages.urls')),
+)
+
+if False:
+    from django.contrib import admin
+
+    urlpatterns += patterns('',
+        url(r'^admin/', include(admin.site.urls)),
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www/wsgi.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,14 @@
+"""
+WSGI config for qmsk_www project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/
+"""
+
+import os
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "qmsk_www.settings")
+
+from django.core.wsgi import get_wsgi_application
+application = get_wsgi_application()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www_pages/pages.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,351 @@
+from django.conf import settings
+
+import codecs
+import datetime
+import logging; log = logging.getLogger('qmsk_www_pages.pages')
+import os, os.path
+
+import markdown
+
+class NotFound (Exception):
+    pass
+
+class Site (object):
+    @classmethod
+    def lookup (cls):
+        return cls(
+            root        = settings.QMSK_WWW_PAGES_DIR,
+            name        = settings.QMSK_WWW_PAGES_SITE,
+        )
+
+    def __init__ (self, root, name):
+        self.root = root
+        self.name = name
+
+class Tree (object):
+    INDEX = 'index'
+
+    @classmethod
+    def lookup (cls, site, parts):
+        """
+            Returns Tree
+
+            Raises NotFound
+        """
+
+        parents = ( )
+        tree = cls(site.root, None, parents, site,
+                title       = site.name,
+        )
+
+        for name in parts:
+            if name.startswith('.'):
+                # evil
+                raise NotFound()
+            
+            if not name:
+                continue
+        
+            path = os.path.join(tree.path, name)
+
+            if not os.path.exists(path):
+                raise NotFound()
+            
+            if not os.path.isdir(path):
+                raise NotFound()
+
+            # title
+            title = tree.item_title(name)
+
+            parents += (tree, )
+            tree = cls(path, name, parents, site,
+                    title   = title,
+            )
+
+        return tree
+
+    def __init__ (self, path, name, parents, site,
+            title   = None,
+    ):
+        """
+            path:       filesystem path
+            name:       subtree name, or None for root
+            parents:    (Tree)
+            site:       Site
+        """
+
+        self.path = path
+        self.name = name
+        self.parents = parents
+        self.site = site
+
+        self.title = title or name
+
+    def hierarchy (self):
+        """
+            Yield Tree.
+        """
+
+        for tree in self.parents:
+            yield tree
+
+        yield self
+
+    def url (self, tree=None, page=None):
+        path = '/'.join(tree.name for tree in self.hierarchy() if tree.name is not None)
+
+        if path:
+            path += '/'
+
+        if tree:
+            path = tree + '/'
+
+        if page:
+            path += page
+
+        return path
+
+    def scan (self):
+        """
+            Scan for files in tree.
+        """
+
+        for filename in os.listdir(self.path):
+            if filename.startswith('.'):
+                continue
+            
+            if '.' in filename:
+                file_name, file_type = filename.rsplit('.', 1)
+            else:
+                file_name = filename
+                file_type = None
+
+            if not file_name:
+                continue
+
+            path = os.path.join(self.path, filename)
+            
+            yield path, file_name, file_type
+
+    def item_title (self, name):
+        """
+            Lookup item title if exists.
+        """
+
+        title_path = os.path.join(self.path, name + '.title')
+
+        log.info("%s: %s title_path=%s", self, name, title_path)
+
+        if os.path.exists(title_path):
+            return open(title_path).read().strip()
+        else:
+            return None
+
+    def list (self):
+        """
+            Lists all Trees and Pages for this Tree.
+
+            Yields (name, url, page_type or None, title)
+        """
+        
+        for path, name, file_type in self.scan():
+            title = self.item_title(name) or name
+
+            # trees
+            if os.path.isdir(path):
+                yield name, self.url(tree=name), None, title
+
+            if name == self.INDEX:
+                continue
+            
+            # pages
+            if not file_type:
+                continue
+
+            if file_type not in TYPES:
+                continue
+
+            yield name, self.url(page=name), file_type, title
+
+    def list_sorted (self):
+        return sorted(list(self.list()))
+
+    def page (self, name):
+        """
+            Scans through tree looking for a matching page.
+            
+            Returns Page or None.
+        """
+        
+        if not name:
+            name = self.INDEX
+            title_default = self.title
+        else:
+            title_default = None
+
+        parents = self.parents + (self, )
+
+        for path, file_name, file_type in self.scan():
+            # match on name
+            if file_name != name and (file_name + '.' + file_type != name):
+                continue
+
+            # redirects?
+            if os.path.islink(path):
+                target = os.readlink(path)
+            
+                # XXX: this should be some kind of common code
+                if '.' in target:
+                    target, target_type = target.rsplit('.', 1)
+
+                log.info("%s: %s -> %s", self, name, target)
+
+                return RedirectPage(path, name, self, parents,
+                    target  = target,
+                )
+            
+            # match on type
+            if not file_type:
+                continue
+
+            page_type = TYPES.get(file_type)
+
+            if not page_type:
+                continue
+            
+            # out
+            title = self.item_title(file_name) or title_default
+
+            return page_type(path, name, self, parents,
+                title   = title,
+            )
+
+class Page (object):
+    ENCODING = 'utf-8'
+
+    @classmethod
+    def lookup (cls, site, page):
+        """
+            Returns Page.
+            
+            Raises NotFound
+        """
+        
+        log.info("page=%r", page)
+
+        if page:
+            parts = page.split('/')
+        else:
+            parts = [ ]
+            
+        if parts:
+            page_name = parts.pop(-1)
+            tree_parts = parts
+        else:
+            page_name = ''
+            tree_parts = []
+
+        # scan dir
+        tree = Tree.lookup(site, tree_parts)
+
+        # scan page
+        page = tree.page(page_name)
+
+        if not page:
+            raise NotFound()
+
+        return page
+
+    def __init__ (self, path, name, tree, parents=(), encoding=ENCODING, title=None):
+        self.path = path
+        self.name = name
+        self.tree = tree
+        self.parents = parents
+
+        self.encoding = encoding
+        self.title = title or name
+
+    def hierarchy (self):
+        """
+            Yield (Tree, name) pairs
+        """
+
+        parent = None
+
+        for tree in self.parents:
+            if parent:
+                yield parent, tree.name
+
+            parent = tree
+
+        yield parent, self.name
+
+    def url (self):
+        return self.tree.url(page=self.name)
+
+    def open (self):
+        return codecs.open(self.path, encoding=self.encoding)
+
+    def stat (self):
+        return os.stat(self.path)
+
+    def breadcrumb (self):
+        for tree in self.tree.hierarchy():
+            yield tree.url(), tree.title
+        
+        if self.name != self.tree.INDEX:
+            yield self.url(), self.title
+
+    def modified (self):
+        return datetime.datetime.utcfromtimestamp(self.stat().st_mtime)
+
+    def redirect_page (self, request):
+        return None
+    
+    def render_html (self, request):
+        raise NotImplementedError()
+
+# TODO: tree redirects
+class RedirectPage (Page):
+    def __init__ (self, path, name, tree, parents,
+            target,
+            **opts
+    ) :
+        super(RedirectPage, self).__init__(path, name, tree, parents, **opts)
+
+        self.target = target
+
+    def redirect_page (self, request):
+        return os.path.normpath(self.tree.url() + '/' + self.target)
+
+class HTML_Page (Page):
+    def render_html (self, request):
+        return self.open().read()
+
+class MarkdownPage (Page):
+    FORMAT = 'html5'
+
+    def __init__ (self, path, name, tree, parents,
+            format=FORMAT,
+            **opts
+    ) :
+        super(MarkdownPage, self).__init__(path, name, tree, parents, **opts)
+
+        self.format = format
+
+    def render_html (self, request):
+        return markdown.markdown(self.open().read(),
+            output_format   = self.format,
+        )
+
+SITE = Site.lookup()
+
+TYPES = {
+    'html':         HTML_Page,
+    'md':           MarkdownPage,
+    'markdown':     MarkdownPage,
+}
+
+def page (page):
+    return Page.lookup(SITE, page)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www_pages/static/pages/pages.css	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,3 @@
+li.page-tree-item i.glyphicon {
+    float: right;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www_pages/templates/pages/page.html	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,51 @@
+{% extends "site.html" %}
+
+{% block title %}{{ site_name }} :: {{ page_title }}{% endblock %}
+
+{% block content %}
+    <div class="container">
+        <div id="header">
+            <h1 class="page-header-title">
+                <a href="{% url 'page' '' %}">{{ site_name }}</a>
+            </h1>
+        </div>
+        <div id="breadcrumb">
+            <ol class="breadcrumb">
+                {% for page, title in page_breadcrumb %}
+                <li><a href="{% url 'page' page %}">{{ title }}</a></li>
+                {% endfor %}
+            </ol>
+        </div>
+        <div class="row">
+            <div class="col-sm-2" id="nav">
+                {% for tree, tree_name in page_hierarchy %}
+                {% if not forloop.first %}
+                <hr />
+                {% endif %}
+                <ul class="nav">
+                    {% for name, page, type, title in tree.list_sorted %}
+                    <li class="page-tree-item{% if type %} page-tree-{{type}}{% else %} page-tree-tree{% endif %}{% if name == tree_name %} page-tree-active{% endif %}">
+                        <a href="{% url 'page' page %}">
+                            {% if not type %}
+                            <i class="glyphicon glyphicon-chevron-right"></i>
+                            {% endif %}
+                            {{ title }}
+                        </a>
+                    </li>
+                    {% endfor %}
+                </ul>
+                {% endfor %}
+            </div>
+            <div class="col-sm-8" id="content">
+                <h1>{{ page_title }}</h1>
+
+                {{ page_html|safe }}
+            </div>
+        </div>
+        <div id="footer">
+            <p class="page-footer-modified">
+                Page modified <span title="{{ page_modified|date:'DATETIME_FORMAT' }}">{{ page_modified|date }}</span>
+            </p>
+        </div>
+    </div>
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www_pages/urls.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,7 @@
+from django.conf.urls import patterns, include, url
+
+from qmsk_www_pages import views
+
+urlpatterns = patterns('',
+    url(r'^(?P<page>.*)$',              views.page,         name='page'),
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qmsk_www_pages/views.py	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,26 @@
+from django.http import Http404
+from django.shortcuts import render, redirect
+
+from qmsk_www_pages import pages
+
+# Create your views here.
+def page (request, page):
+    try:
+        page = pages.page(page)
+    except pages.NotFound:
+        raise Http404
+
+    redirect_page = page.redirect_page(request)
+
+    if redirect_page:
+        return redirect('page', redirect_page)
+
+    return render(request, 'pages/page.html', dict(
+            site_name       = page.tree.site.name,
+            page_name       = page.name,
+            page_title      = page.title,
+            page_breadcrumb = page.breadcrumb(),
+            page_hierarchy  = list(page.hierarchy()),
+            page_html       = page.render_html(request),
+            page_modified   = page.modified(),
+    ))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/requirements.txt	Sun Sep 14 13:05:35 2014 +0300
@@ -0,0 +1,2 @@
+Django==1.7
+Markdown==2.5
Binary file static/link.png has changed
--- a/static/style.css	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,195 +0,0 @@
-
-/*
- * Three main areas:
- *  header
- *  nav
- *  content
- *
- * Border layout:
- *      Header (bottom)
- *      Content (left)
- */
-
-/*
- * Global styles
- */
-body {
-    padding: 0px;
-    margin: 0px;
-
-    background-color: #ffffff;
-    color: #000000;
-}
-
-/*
- * Top header
- */
-div#header {
-    padding: 30px;
-    
-    font-size: 48pt;
-    font-weight: bold;
-
-    border-bottom: 1px dashed #a5a5a5;
-}
-
-div#header a:hover {
-    text-decoration: none;
-}
-
-/*
- * Container for layout items
- */
- #container {
-    overflow: hidden;
-}
-
-/*
- * Main navigation menu
- */
-#nav { 
-    float: left;
-    
-    padding-bottom: 2000px;
-    margin-bottom: -2000px;
-    
-    border-right: 1px dashed #a5a5a5;
-}
-
-#nav ul {
-    margin: 0px;
-    padding: 0px;
-
-    list-style: none;
-
-    width: 180px;
-
-    padding-top: 25px;
-}
-
-#nav ul ul {
-    padding-top: 0px;
-    border-left: 15px solid #d0d0d0;
-}
-
-#nav li a {
-    display: block;
-    
-    padding: 10px;
-    padding-left: 20px;
-}
-
-#nav li a:hover {
-    background-color: #d0d0d0;
-    text-decoration: none;
-}
-
-#nav li a.selected-page {
-    border-left: 5px solid black;
-    padding-left: 15px;
-}
-
-/*
- * Content
- */
-div#content {
-    margin-left: 180px;
-    padding: 25px;
-
-    padding-right: 50px;
-}
-
-div#breadcrumb {
-    font-size: x-small;
-}
-
-/*
- * Footer
- */
-div#footer {
-    padding: 10px;
-
-    border-top: 1px dashed #a5a5a5;
-
-    font-size: x-small;
-    font-style: italic;
-}
-
-div#footer-left {
-    float: left;
-}
-
-div#footer-right {
-    float: right;
-    text-align: right;
-}
-
-div#footer-center {
-    text-align: center;
-}
-
-/*
- * General styles
- */
-a {
-    color: black;
-    text-decoration: none;
-    font-weight: bold;
-}
-
-a:hover {
-    text-decoration: underline;
-}
-
-h1 {
-    font-size: xx-large;
-
-    text-align: center;
-}
-
-h2 {
-    font-size: large;
-    
-    margin-left: 0px;
-    padding: 5px;
-    width: 100%;
-
-    background-color: #e5e5e5;
-    
-    border: 1px dashed #c5c5c5;
-}
-
-h3 {
-    font-size: medium;
-    font-style: italic;
-}
-
-div#content p {
-    margin-left: 0.5em;
-}
-
-#content li {
-    padding: 2px;
-}
-
-#content a {
-    padding-right: 13px;
-    
-    background: transparent url(/static/link.png) no-repeat center right;
-}
-
-dt {
-    font-size: large;
-}
-
-dd {
-    margin: 1em auto 1em 5em;
-}
-
-code {
-    display: block;
-    margin: 8px;
-    padding: 8px;
-
-    border: 1px dotted #b5b5b5;
-}
--- a/templates/layout.tmpl	Sun Sep 14 12:55:49 2014 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,56 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-
-<%def name="render_menu(open_page, page, items, ancestry)">
-<ul>
-% for pi in items :
-    <li>
-        <a href="${req.page_prefix}/${pi.url}"${' class="selected-page"' if pi == open_page else ''}>${pi.title} ${'&raquo;' if pi.children and pi.parent else ''}</a>
-    % if pi in ancestry and pi.children and pi.parent :
-        ${render_menu(page, pi, pi.children, ancestry)}
-    % endif
-    </li>
-% endfor
-</ul>
-</%def>
-
-<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
-    <head>
-        <title>qmsk.net ${' :: ' + h.breadcrumb(menu.ancestry, links=False) if menu.ancestry else ''}</title>
-        <link rel="Stylesheet" type="text/css" href="${req.site_root}/static/style.css" />
-    </head>
-    <body>
-            <div id="header">
-                <a href="${req.page_prefix}/">QMSK.NET</a>
-            </div>
-            
-            <div id="container">
-                <div id="nav">
-                    ${render_menu(menu.page, menu.page, menu.items, menu.ancestry)}
-                </div>
-
-                <div id="content">
-                    <div id="breadcrumb">
-                        <!-- ${h.breadcrumb(menu.ancestry)} -->
-                    </div>
-                    ${page.content}
-                </div>
-                
-            </div>
-
-            <div id="footer">
-                <div id="footer-right">
-                    Page Modified ${page.modified} <br/>
-                    Current time ${h.now()}
-                </div>
-               
-                <div id="footer-left">
-                    &copy; ${h.copyright_year()} Tero Marttila
-                </div>
-                
-                <div id="footer-center">
-                    ${h.validation_notice(req.site_host)}
-                </div>
-            </div>
-    </body>
-</html>
-