changeset 48:9658edea3121

Now using RequireJS' "text" extension to load all HTML templates.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 26 Jan 2013 22:17:51 -0800
parents 86ee1b696070
children fb6ae96756c1
files wikked/static/js/text.js wikked/static/js/wikked.js wikked/static/js/wikked/app.js wikked/static/js/wikked/client.js wikked/static/js/wikked/models.js wikked/static/js/wikked/views.js wikked/static/tpl/inlinks-page.html wikked/static/tpl/login.html wikked/templates/index.html
diffstat 9 files changed, 573 insertions(+), 180 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/static/js/text.js	Sat Jan 26 22:17:51 2013 -0800
@@ -0,0 +1,323 @@
+/**
+ * @license RequireJS text 2.0.4 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved.
+ * Available via the MIT or new BSD license.
+ * see: http://github.com/requirejs/text for details
+ */
+/*jslint regexp: true */
+/*global require: false, XMLHttpRequest: false, ActiveXObject: false,
+  define: false, window: false, process: false, Packages: false,
+  java: false, location: false */
+
+define(['module'], function (module) {
+    'use strict';
+
+    var text, fs,
+        progIds = ['Msxml2.XMLHTTP', 'Microsoft.XMLHTTP', 'Msxml2.XMLHTTP.4.0'],
+        xmlRegExp = /^\s*<\?xml(\s)+version=[\'\"](\d)*.(\d)*[\'\"](\s)*\?>/im,
+        bodyRegExp = /<body[^>]*>\s*([\s\S]+)\s*<\/body>/im,
+        hasLocation = typeof location !== 'undefined' && location.href,
+        defaultProtocol = hasLocation && location.protocol && location.protocol.replace(/\:/, ''),
+        defaultHostName = hasLocation && location.hostname,
+        defaultPort = hasLocation && (location.port || undefined),
+        buildMap = [],
+        masterConfig = (module.config && module.config()) || {};
+
+    text = {
+        version: '2.0.4',
+
+        strip: function (content) {
+            //Strips <?xml ...?> declarations so that external SVG and XML
+            //documents can be added to a document without worry. Also, if the string
+            //is an HTML document, only the part inside the body tag is returned.
+            if (content) {
+                content = content.replace(xmlRegExp, "");
+                var matches = content.match(bodyRegExp);
+                if (matches) {
+                    content = matches[1];
+                }
+            } else {
+                content = "";
+            }
+            return content;
+        },
+
+        jsEscape: function (content) {
+            return content.replace(/(['\\])/g, '\\$1')
+                .replace(/[\f]/g, "\\f")
+                .replace(/[\b]/g, "\\b")
+                .replace(/[\n]/g, "\\n")
+                .replace(/[\t]/g, "\\t")
+                .replace(/[\r]/g, "\\r")
+                .replace(/[\u2028]/g, "\\u2028")
+                .replace(/[\u2029]/g, "\\u2029");
+        },
+
+        createXhr: masterConfig.createXhr || function () {
+            //Would love to dump the ActiveX crap in here. Need IE 6 to die first.
+            var xhr, i, progId;
+            if (typeof XMLHttpRequest !== "undefined") {
+                return new XMLHttpRequest();
+            } else if (typeof ActiveXObject !== "undefined") {
+                for (i = 0; i < 3; i += 1) {
+                    progId = progIds[i];
+                    try {
+                        xhr = new ActiveXObject(progId);
+                    } catch (e) {}
+
+                    if (xhr) {
+                        progIds = [progId];  // so faster next time
+                        break;
+                    }
+                }
+            }
+
+            return xhr;
+        },
+
+        /**
+         * Parses a resource name into its component parts. Resource names
+         * look like: module/name.ext!strip, where the !strip part is
+         * optional.
+         * @param {String} name the resource name
+         * @returns {Object} with properties "moduleName", "ext" and "strip"
+         * where strip is a boolean.
+         */
+        parseName: function (name) {
+            var modName, ext, temp,
+                strip = false,
+                index = name.indexOf("."),
+                isRelative = name.indexOf('./') === 0 ||
+                             name.indexOf('../') === 0;
+
+            if (index !== -1 && (!isRelative || index > 1)) {
+                modName = name.substring(0, index);
+                ext = name.substring(index + 1, name.length);
+            } else {
+                modName = name;
+            }
+
+            temp = ext || modName;
+            index = temp.indexOf("!");
+            if (index !== -1) {
+                //Pull off the strip arg.
+                strip = temp.substring(index + 1) === "strip";
+                temp = temp.substring(0, index);
+                if (ext) {
+                    ext = temp;
+                } else {
+                    modName = temp;
+                }
+            }
+
+            return {
+                moduleName: modName,
+                ext: ext,
+                strip: strip
+            };
+        },
+
+        xdRegExp: /^((\w+)\:)?\/\/([^\/\\]+)/,
+
+        /**
+         * Is an URL on another domain. Only works for browser use, returns
+         * false in non-browser environments. Only used to know if an
+         * optimized .js version of a text resource should be loaded
+         * instead.
+         * @param {String} url
+         * @returns Boolean
+         */
+        useXhr: function (url, protocol, hostname, port) {
+            var uProtocol, uHostName, uPort,
+                match = text.xdRegExp.exec(url);
+            if (!match) {
+                return true;
+            }
+            uProtocol = match[2];
+            uHostName = match[3];
+
+            uHostName = uHostName.split(':');
+            uPort = uHostName[1];
+            uHostName = uHostName[0];
+
+            return (!uProtocol || uProtocol === protocol) &&
+                   (!uHostName || uHostName.toLowerCase() === hostname.toLowerCase()) &&
+                   ((!uPort && !uHostName) || uPort === port);
+        },
+
+        finishLoad: function (name, strip, content, onLoad) {
+            content = strip ? text.strip(content) : content;
+            if (masterConfig.isBuild) {
+                buildMap[name] = content;
+            }
+            onLoad(content);
+        },
+
+        load: function (name, req, onLoad, config) {
+            //Name has format: some.module.filext!strip
+            //The strip part is optional.
+            //if strip is present, then that means only get the string contents
+            //inside a body tag in an HTML string. For XML/SVG content it means
+            //removing the <?xml ...?> declarations so the content can be inserted
+            //into the current doc without problems.
+
+            // Do not bother with the work if a build and text will
+            // not be inlined.
+            if (config.isBuild && !config.inlineText) {
+                onLoad();
+                return;
+            }
+
+            masterConfig.isBuild = config.isBuild;
+
+            var parsed = text.parseName(name),
+                nonStripName = parsed.moduleName +
+                    (parsed.ext ? '.' + parsed.ext : ''),
+                url = req.toUrl(nonStripName),
+                useXhr = (masterConfig.useXhr) ||
+                         text.useXhr;
+
+            //Load the text. Use XHR if possible and in a browser.
+            if (!hasLocation || useXhr(url, defaultProtocol, defaultHostName, defaultPort)) {
+                text.get(url, function (content) {
+                    text.finishLoad(name, parsed.strip, content, onLoad);
+                }, function (err) {
+                    if (onLoad.error) {
+                        onLoad.error(err);
+                    }
+                });
+            } else {
+                //Need to fetch the resource across domains. Assume
+                //the resource has been optimized into a JS module. Fetch
+                //by the module name + extension, but do not include the
+                //!strip part to avoid file system issues.
+                req([nonStripName], function (content) {
+                    text.finishLoad(parsed.moduleName + '.' + parsed.ext,
+                                    parsed.strip, content, onLoad);
+                });
+            }
+        },
+
+        write: function (pluginName, moduleName, write, config) {
+            if (buildMap.hasOwnProperty(moduleName)) {
+                var content = text.jsEscape(buildMap[moduleName]);
+                write.asModule(pluginName + "!" + moduleName,
+                               "define(function () { return '" +
+                                   content +
+                               "';});\n");
+            }
+        },
+
+        writeFile: function (pluginName, moduleName, req, write, config) {
+            var parsed = text.parseName(moduleName),
+                extPart = parsed.ext ? '.' + parsed.ext : '',
+                nonStripName = parsed.moduleName + extPart,
+                //Use a '.js' file name so that it indicates it is a
+                //script that can be loaded across domains.
+                fileName = req.toUrl(parsed.moduleName + extPart) + '.js';
+
+            //Leverage own load() method to load plugin value, but only
+            //write out values that do not have the strip argument,
+            //to avoid any potential issues with ! in file names.
+            text.load(nonStripName, req, function (value) {
+                //Use own write() method to construct full module value.
+                //But need to create shell that translates writeFile's
+                //write() to the right interface.
+                var textWrite = function (contents) {
+                    return write(fileName, contents);
+                };
+                textWrite.asModule = function (moduleName, contents) {
+                    return write.asModule(moduleName, fileName, contents);
+                };
+
+                text.write(pluginName, nonStripName, textWrite, config);
+            }, config);
+        }
+    };
+
+    if (masterConfig.env === 'node' || (!masterConfig.env &&
+            typeof process !== "undefined" &&
+            process.versions &&
+            !!process.versions.node)) {
+        //Using special require.nodeRequire, something added by r.js.
+        fs = require.nodeRequire('fs');
+
+        text.get = function (url, callback) {
+            var file = fs.readFileSync(url, 'utf8');
+            //Remove BOM (Byte Mark Order) from utf8 files if it is there.
+            if (file.indexOf('\uFEFF') === 0) {
+                file = file.substring(1);
+            }
+            callback(file);
+        };
+    } else if (masterConfig.env === 'xhr' || (!masterConfig.env &&
+            text.createXhr())) {
+        text.get = function (url, callback, errback) {
+            var xhr = text.createXhr();
+            xhr.open('GET', url, true);
+
+            //Allow overrides specified in config
+            if (masterConfig.onXhr) {
+                masterConfig.onXhr(xhr, url);
+            }
+
+            xhr.onreadystatechange = function (evt) {
+                var status, err;
+                //Do not explicitly handle errors, those should be
+                //visible via console output in the browser.
+                if (xhr.readyState === 4) {
+                    status = xhr.status;
+                    if (status > 399 && status < 600) {
+                        //An http 4xx or 5xx error. Signal an error.
+                        err = new Error(url + ' HTTP status: ' + status);
+                        err.xhr = xhr;
+                        errback(err);
+                    } else {
+                        callback(xhr.responseText);
+                    }
+                }
+            };
+            xhr.send(null);
+        };
+    } else if (masterConfig.env === 'rhino' || (!masterConfig.env &&
+            typeof Packages !== 'undefined' && typeof java !== 'undefined')) {
+        //Why Java, why is this so awkward?
+        text.get = function (url, callback) {
+            var stringBuffer, line,
+                encoding = "utf-8",
+                file = new java.io.File(url),
+                lineSeparator = java.lang.System.getProperty("line.separator"),
+                input = new java.io.BufferedReader(new java.io.InputStreamReader(new java.io.FileInputStream(file), encoding)),
+                content = '';
+            try {
+                stringBuffer = new java.lang.StringBuffer();
+                line = input.readLine();
+
+                // Byte Order Mark (BOM) - The Unicode Standard, version 3.0, page 324
+                // http://www.unicode.org/faq/utf_bom.html
+
+                // Note that when we use utf-8, the BOM should appear as "EF BB BF", but it doesn't due to this bug in the JDK:
+                // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4508058
+                if (line && line.length() && line.charAt(0) === 0xfeff) {
+                    // Eat the BOM, since we've already found the encoding on this file,
+                    // and we plan to concatenating this buffer with others; the BOM should
+                    // only appear at the top of a file.
+                    line = line.substring(1);
+                }
+
+                stringBuffer.append(line);
+
+                while ((line = input.readLine()) !== null) {
+                    stringBuffer.append(lineSeparator);
+                    stringBuffer.append(line);
+                }
+                //Make sure we return a JavaScript string and not a Java string.
+                content = String(stringBuffer.toString()); //String
+            } finally {
+                input.close();
+            }
+            callback(content);
+        };
+    }
+
+    return text;
+});
--- a/wikked/static/js/wikked.js	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/js/wikked.js	Sat Jan 26 22:17:51 2013 -0800
@@ -44,9 +44,10 @@
         'wikked/app',
         'wikked/handlebars',
         'backbone',
-        'bootstrap'
+        'bootstrap',
+        'text'
         ],
-    function(app, hb, Backbone) {
+    function(app, hb, Backbone, textExtension) {
 
     var router = new app.Router();
     Backbone.history.start();//{ pushState: true });
--- a/wikked/static/js/wikked/app.js	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/js/wikked/app.js	Sat Jan 26 22:17:51 2013 -0800
@@ -32,8 +32,8 @@
         readPage: function(path) {
             path_clean = this.stripQuery(path);
             no_redirect = this.getQueryVariable('no_redirect', path);
-            var view = new Views.PageReadView({ 
-                el: $('#app'), 
+            var view = new Views.PageReadView({
+                el: $('#app'),
                 model: new Models.PageReadModel({ path: path_clean })
             });
             if (no_redirect) {
@@ -47,7 +47,7 @@
             this.readPage('main-page');
         },
         editPage: function(path) {
-            var view = new Views.PageEditView({ 
+            var view = new Views.PageEditView({
                 el: $('#app'),
                 model: new Models.PageEditModel({ path: path })
             });
@@ -76,7 +76,7 @@
         readPageRevision: function(path, rev) {
             var view = new Views.PageRevisionView({
                 el: $('#app'),
-                rev: rev, 
+                rev: rev,
                 model: new Models.PageRevisionModel({ path: path, rev: rev })
             });
             view.model.setApp(this);
@@ -122,6 +122,7 @@
                 model: new Models.LoginModel()
             });
             view.model.setApp(this);
+            view.render();
             this.navigate('/login');
         },
         doLogout: function() {
@@ -140,10 +141,24 @@
                 model: new Models.SpecialPagesModel()
             });
             view.model.setApp(this);
+            view.render();
             this.navigate('/special');
         },
         showSpecialPage: function(page) {
-            var view = new Views.GenericSpecialPageView({
+            var viewType = false;
+            switch (page) {
+                case "changes":
+                    viewType = Views.SpecialChangesView;
+                    break;
+                case "orphans":
+                    viewType = Views.SpecialOrphansView;
+                    break;
+            }
+            if (viewType === false) {
+                console.error("Unsupported special page: ", page);
+                return;
+            }
+            var view = new viewType({
                 el: $('#app'),
                 model: new Models.GenericSpecialPageModel({ page: page })
             });
--- a/wikked/static/js/wikked/client.js	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/js/wikked/client.js	Sat Jan 26 22:17:51 2013 -0800
@@ -132,7 +132,7 @@
                 if (
                     directory === ".." && directories.length && prev !== ".." && prev !== "." && prev !== undefined && (prev !== "" || keepBlanks)) {
                     directories.pop();
-                prev = directories.slice(-1)[0]
+                prev = directories.slice(-1)[0];
             } else {
                 if (prev === ".") directories.pop();
                 directories.push(directory);
@@ -158,7 +158,7 @@
             if (link[0] == '/') {
                 abs_link = link.substring(1);
             } else {
-                raw_abs_link = this.baseUrl + link
+                raw_abs_link = this.baseUrl + link;
                 abs_link = normalizeArray(raw_abs_link.split('/')).join('/');
             }
             ansi_link = removeDiacritics(abs_link);
@@ -166,7 +166,7 @@
         },
         formatText: function(text) {
             var $f = this;
-            text = text.replace(/^\[\[((__|\+)?[a-zA-Z][a-zA-Z0-9_\-]+)\:\s*(.*)\]\]\s*$/gm, function(m, a, b, c) {
+            text = text.replace(/^\{\{((__|\+)?[a-zA-Z][a-zA-Z0-9_\-]+)\:\s*(.*)\}\}\s*$/gm, function(m, a, b, c) {
                 if (!c) {
                     c = 'true';
                 }
--- a/wikked/static/js/wikked/models.js	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/js/wikked/models.js	Sat Jan 26 22:17:51 2013 -0800
@@ -109,7 +109,7 @@
                     $model.app.navigate('/', { trigger: true });
                 })
                 .error(function() {
-                    alert("Error while logging in...");
+                    $model.set('has_error', true);
                 });
         }
     });
@@ -159,11 +159,11 @@
             this.set('content', new Handlebars.SafeString(text));
         }
     });
-    
+
     var PageStateModel = exports.PageStateModel = PageModel.extend({
         urlRoot: '/api/state/'
     });
-    
+
     var MasterPageModel = exports.MasterPageModel = PageModel.extend({
         initialize: function() {
             this.nav = new NavigationModel({ id: this.id });
@@ -216,8 +216,14 @@
         initialize: function() {
             PageReadModel.__super__.initialize.apply(this, arguments);
             this.on('change', this._onChange, this);
+
+            // Add extra links to the footer.
+            var model = this;
+            this.footer.addExtraUrl('Pages Linking Here', function() { return '/#/inlinks/' + model.id; }, 1);
+            this.footer.addExtraUrl('JSON', function() { return '/api/read/' + model.id; });
         },
         _onChange: function() {
+            // Handle redirects.
             if (this.getMeta('redirect') && !this.get('no_redirect')) {
                 var oldPath = this.get('path');
                 this.set('path', this.getMeta('redirect'));
@@ -228,11 +234,6 @@
                 });
                 this.app.navigate('/read/' + this.getMeta('redirect'), { replace: true });
             }
-        },
-        _onChangePath: function(path) {
-            PageReadModel.__super__._onChangePath.apply(this, arguments);
-            this.footer.addExtraUrl('Pages Linking Here', '/#/inlinks/' + path, 1);
-            this.footer.addExtraUrl('JSON', '/api/read/' + path);
         }
     });
 
--- a/wikked/static/js/wikked/views.js	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/js/wikked/views.js	Sat Jan 26 22:17:51 2013 -0800
@@ -8,9 +8,31 @@
         'handlebars',
         './client',
         './models',
-        './util'
+        './util',
+        'text!/tpl/read-page.html',
+        'text!/tpl/edit-page.html',
+        'text!/tpl/history-page.html',
+        'text!/tpl/revision-page.html',
+        'text!/tpl/diff-page.html',
+        'text!/tpl/inlinks-page.html',
+        'text!/tpl/nav.html',
+        'text!/tpl/footer.html',
+        'text!/tpl/search-results.html',
+        'text!/tpl/login.html',
+        'text!/tpl/error-unauthorized.html',
+        'text!/tpl/error-not-found.html',
+        'text!/tpl/error-unauthorized-edit.html',
+        'text!/tpl/state-warning.html',
+        'text!/tpl/special-nav.html',
+        'text!/tpl/special-pages.html',
+        'text!/tpl/special-changes.html',
+        'text!/tpl/special-orphans.html'
         ],
-    function($, _, Backbone, Handlebars, Client, Models, Util) {
+    function($, _, Backbone, Handlebars, Client, Models, Util,
+        tplReadPage, tplEditPage, tplHistoryPage, tplRevisionPage, tplDiffPage, tplInLinksPage,
+        tplNav, tplFooter, tplSearchResults, tplLogin,
+        tplErrorNotAuthorized, tplErrorNotFound, tplErrorUnauthorizedEdit, tplStateWarning,
+        tplSpecialNav, tplSpecialPages, tplSpecialChanges, tplSpecialOrphans) {
 
     var exports = {};
 
@@ -23,24 +45,24 @@
                 var $view = this;
                 this.model.on("change", function() { $view._onModelChange(); });
             }
+            if (this.templateSource !== undefined) {
+                this.template = Handlebars.compile(_.result(this, 'templateSource'));
+            }
             return this;
         },
         render: function(view) {
-            if (this.templateName !== undefined) {
-                this.renderTemplate(_.result(this, 'templateName'), this.renderCallback);
+            console.log("Rendering!");
+            if (this.template !== undefined) {
+                this.renderTemplate(this.template);
+                if (this.renderCallback !== undefined) {
+                    this.renderCallback();
+                }
             }
             this.renderTitle(this.titleFormat);
             return this;
         },
-        renderTemplate: function(tpl_name, callback) {
-            var $view = this;
-            Util.TemplateLoader.get(tpl_name, function(src) {
-                var template = Handlebars.compile(src);
-                $view.$el.html(template($view.model.toJSON()));
-                if (callback !== undefined) {
-                    callback.call($view, $view, $view.model);
-                }
-            });
+        renderTemplate: function(tpl) {
+            this.$el.html(tpl(this.model.toJSON()));
         },
         renderTitle: function(formatter) {
             var title = this.model.title();
@@ -50,100 +72,100 @@
             document.title = title;
         },
         _onModelChange: function() {
+            console.log("Model changed!");
             this.render();
         }
     });
     _.extend(PageView, Backbone.Events);
 
     var NavigationView = exports.NavigationView = PageView.extend({
-        templateName: 'nav',
+        templateSource: tplNav,
         initialize: function() {
             NavigationView.__super__.initialize.apply(this, arguments);
-            this.render();
             return this;
         },
         render: function() {
-            this.renderTemplate(this.templateName);
+            console.log("Rendering navigation!");
+            this.renderTemplate(this.template);
+            this.origPageEl = $('#app .page');
+            return this;
+        },
+        events: {
+            "submit #search": "_submitSearch",
+            "input #search>.search-query": "_previewSearch",
+            "keyup #search>.search-query": "_searchQueryChanged"
+        },
+        _submitSearch: function(e) {
+            e.preventDefault();
+            this.model.doSearch(e.currentTarget);
+            return false;
         },
-        postRender: function() {
-            var model = this.model;
-            this.$('#search').submit(function(e) {
-                e.preventDefault();
-                model.doSearch(this);
-                return false;
-            });
-            var $view = this;
-            Util.TemplateLoader.get('search-results', function(src) {
-                var template = Handlebars.compile(src);
-                var origPageEl = $('#app .page');
-
-                $view.$('#search .search-query')
-                    .on('input', function() {
-                        var curPreviewEl = $('#app .page[class~="preview-search-results"]');
-
-                        // Restore the original content if the query is now
-                        // empty. Otherwise, run a search and render only the
-                        // `.page` portion of the results page.
-                        var query = $(this).val();
-                        if (query && query.length > 0) {
-                            model.doPreviewSearch(query, function(data) {
-                                data.is_instant = true;
-                                var resultList = $(template(data));
-                                var inner = $('.page', resultList)
-                                    .addClass('preview-search-results')
-                                    .detach();
-                                if (origPageEl.is(':visible')) {
-                                    inner.insertAfter(origPageEl);
-                                    origPageEl.hide();
-                                } else {
-                                    curPreviewEl.replaceWith(inner);
-                                }
-                            });
-                        } else {
-                            curPreviewEl.remove();
-                            origPageEl.show();
-                        }
-                    })
-                    .keyup(function(e) {
-                        if (e.keyCode == 27) {
-                            // Clear search on `Esc`.
-                            $(this).val('').trigger('input');
-                        }
-                    });
-            });
+        _previewSearch: function(e) {
+            // Restore the original content if the query is now
+            // empty. Otherwise, run a search and render only the
+            // `.page` portion of the results page.
+            var origPageEl = this.origPageEl;
+            var curPreviewEl = $('#app .page[class~="preview-search-results"]');
+            var query = $(e.currentTarget).val();
+            if (query && query.length > 0) {
+                var template = Handlebars.compile(tplSearchResults);
+                this.model.doPreviewSearch(query, function(data) {
+                    data.is_instant = true;
+                    var resultList = $(template(data));
+                    var inner = $('.page', resultList)
+                        .addClass('preview-search-results')
+                        .detach();
+                    if (origPageEl.is(':visible')) {
+                        inner.insertAfter(origPageEl);
+                        origPageEl.hide();
+                    } else {
+                        curPreviewEl.replaceWith(inner);
+                    }
+                });
+            } else {
+                curPreviewEl.remove();
+                origPageEl.show();
+            }
+        },
+        _searchQueryChanged: function(e) {
+            if (e.keyCode == 27) {
+                // Clear search on `Esc`.
+                $(e.currentTarget).val('').trigger('input');
+            }
         }
     });
 
     var FooterView = exports.FooterView = PageView.extend({
-        templateName:  'footer',
+        templateSource: tplFooter,
         initialize: function() {
             FooterView.__super__.initialize.apply(this, arguments);
-            this.render();
             return this;
         },
         render: function() {
-            this.renderTemplate('footer');
-        },
-        postRender: function() {
+            console.log("Rendering footer!");
+            this.renderTemplate(this.template);
+            return this;
         }
     });
 
     var LoginView = exports.LoginView = PageView.extend({
-        templateName: 'login',
+        templateSource: tplLogin,
         initialize: function() {
             LoginView.__super__.initialize.apply(this, arguments);
-            this.render();
             return this;
         },
         render: function() {
-            this.renderTemplate('login', function(view, model) {
-                this.$('#login').submit(function(e) {
-                    e.preventDefault();
-                    model.doLogin(this);
-                    return false;
-                });
-            });
+            this.renderTemplate(this.template);
             document.title = 'Login';
+            return this;
+        },
+        events: {
+            "submit #login": "_submitLogin"
+        },
+        _submitLogin: function(e) {
+            e.preventDefault();
+            this.model.doLogin(e.currentTarget);
+            return false;
         }
     });
 
@@ -152,23 +174,22 @@
             MasterPageView.__super__.initialize.apply(this, arguments);
             this.nav = this._createNavigation(this.model.nav);
             this.footer = this._createFooter(this.model.footer);
-            this.render();
             return this;
         },
-        renderCallback: function(view, model) {
-            this.nav.$el.prependTo(this.$el);
-            this.nav.postRender();
-            this.footer.$el.appendTo(this.$el);
-            this.footer.postRender();
+        renderCallback: function() {
+            this.$el.prepend('<div id="nav"></div>');
+            this.$el.append('<div id="footer"></div>');
+            this.nav.setElement(this.$('#nav')).render();
+            this.footer.setElement(this.$('#footer')).render();
         },
-        templateName: function() {
+        templateSource: function() {
             switch (this.model.get('error_code')) {
                 case 401:
-                    return 'error-unauthorized';
+                    return tplErrorNotAuthorized;
                 case 404:
-                    return 'error-not-found';
+                    return tplErrorNotFound;
                 default:
-                    return _.result(this, 'defaultTemplateName');
+                    return _.result(this, 'defaultTemplateSource');
             }
         },
         _createNavigation: function(model) {
@@ -180,12 +201,15 @@
     });
 
     var PageReadView = exports.PageReadView = MasterPageView.extend({
-        defaultTemplateName: 'read-page',
+        defaultTemplateSource: tplReadPage,
         initialize: function() {
+            console.log("Initializing PageReadView");
             PageReadView.__super__.initialize.apply(this, arguments);
+            this.warningTemplate = Handlebars.compile(tplStateWarning);
             return this;
         },
-        renderCallback: function(view, model) {
+        renderCallback: function() {
+            console.log("Rendering PageReadView: " + this.model.get('path'));
             PageReadView.__super__.renderCallback.apply(this, arguments);
             // Replace all wiki links with proper hyperlinks using the JS app's
             // URL scheme.
@@ -197,21 +221,38 @@
                     jel.attr('href', '/#/read/' + jel.attr('data-wiki-url'));
             });
         },
-        _fetchState: function() {
+        events: {
+            "click .wiki-link": "_navigateLink"
+        },
+        _navigateLink: function(e) {
+            var url = $(e.currentTarget).attr('data-wiki-url');
+            this.model.app.navigate('/read/' + url);
+            this.model.set('path', url);
+            this.model.fetch();
+            e.preventDefault();
+            return false;
+        },
+        _lastFetchedStatePath: false,
+        _onModelChange: function() {
+            PageReadView.__super__._onModelChange.apply(this, arguments);
+
+            // Fetch the state if the current page changed.
+            if (this._lastFetchedStatePath == this.model.get('path'))
+                return;
+            this._lastFetchedStatePath = this.model.get('path');
+
+            var stateTpl = this.warningTemplate;
             var stateModel = new Models.PageStateModel({ path: this.model.get('path') });
             stateModel.fetch({
                 success: function(model, response, options) {
                     if (model.get('state') == 'new' || model.get('state') == 'modified') {
-                        Util.TemplateLoader.get('state-warning', function(src) {
-                            var template = Handlebars.compile(src);
-                            var warning = $(template(model.toJSON()));
-                            warning.css('display', 'none');
-                            warning.prependTo($('#app .page'));
-                            warning.slideDown();
-                            $('.dismiss', warning).click(function() {
-                                warning.slideUp();
-                                return false;
-                            });
+                        var warning = $(stateTpl(model.toJSON()));
+                        warning.css('display', 'none');
+                        warning.prependTo($('#app .page'));
+                        warning.slideDown();
+                        $('.dismiss', warning).click(function() {
+                            warning.slideUp();
+                            return false;
                         });
                     }
                 }
@@ -220,15 +261,13 @@
     });
 
     var PageEditView = exports.PageEditView = MasterPageView.extend({
-        templateName: function() {
-            switch (this.model.get('error_code')) {
-                case 401:
-                    return 'error-unauthorized-edit';
-                default:
-                    return 'edit-page';
+        templateSource: function() {
+            if (this.model.get('error_code') == 401) {
+                return tplErrorUnauthorizedEdit;
             }
+            return tplEditPage;
         },
-        renderCallback: function(view, model) {
+        renderCallback: function() {
             PageEditView.__super__.renderCallback.apply(this, arguments);
 
             // Create the Markdown editor.
@@ -242,42 +281,43 @@
             editor.run();
             var editor_control = this.$('textarea#wmd-input');
             editor_control.outerWidth(this.$('.wmd-input-wrapper').innerWidth());
-
+        },
+        events: {
+            "mousedown .wmd-input-grip": "_inputGripMouseDown",
+            "click .wmd-preview-wrapper>h3>a": "_togglePreview",
+            "submit #page-edit": "_submitEditedPage"
+        },
+        _inputGripMouseDown: function(e) {
             // Input area resizing with the grip.
             var last_pageY;
-            this.$(".wmd-input-grip")
-                .mousedown(function(e) {
+            last_pageY = e.pageY;
+            $('body')
+                .on('mousemove.wikked.editor_resize', function(e) {
+                    editor_control.height(editor_control.height() + e.pageY - last_pageY);
                     last_pageY = e.pageY;
-                    $('body')
-                        .on('mousemove.wikked.editor_resize', function(e) {
-                            editor_control.height(editor_control.height() + e.pageY - last_pageY);
-                            last_pageY = e.pageY;
-                        })
-                        .on('mouseup.wikked.editor_resize mouseleave.wikked.editor_resize', function(e) {
-                            $('body').off('.wikked.editor_resize');
-                        });
+                })
+                .on('mouseup.wikked.editor_resize mouseleave.wikked.editor_resize', function(e) {
+                    $('body').off('.wikked.editor_resize');
                 });
-
+        },
+        _togglePreview: function(e) {
             // Show/hide live preview.
-            this.$('.wmd-preview-wrapper>h3>a').on('click', function(e) {
-                $('#wmd-preview').fadeToggle(function() {
-                    var icon = $('.wmd-preview-wrapper>h3>a i');
-                    if (icon.hasClass('icon-minus')) {
-                        icon.removeClass('icon-minus');
-                        icon.addClass('icon-plus');
-                    } else {
-                        icon.removeClass('icon-plus');
-                        icon.addClass('icon-minus');
-                    }
-                });
+            this.$('#wmd-preview').fadeToggle(function() {
+                var icon = this.$('.wmd-preview-wrapper>h3>a i');
+                if (icon.hasClass('icon-minus')) {
+                    icon.removeClass('icon-minus');
+                    icon.addClass('icon-plus');
+                } else {
+                    icon.removeClass('icon-plus');
+                    icon.addClass('icon-minus');
+                }
             });
-
+        },
+        _submitEditedPage: function(e) {
             // Make the model submit the form.
-            this.$('#page-edit').submit(function(e) {
-                e.preventDefault();
-                model.doEdit(this);
-                return false;
-            });
+            e.preventDefault();
+            this.model.doEdit(e.currentTarget);
+            return false;
         },
         titleFormat: function(title) {
             return 'Editing: ' + title;
@@ -285,14 +325,14 @@
     });
 
     var PageHistoryView = exports.PageHistoryView = MasterPageView.extend({
-        defaultTemplateName: 'history-page',
-        renderCallback: function(view, model) {
-            PageHistoryView.__super__.renderCallback.apply(this, arguments);
-            this.$('#diff-page').submit(function(e) {
-                e.preventDefault();
-                model.doDiff(this);
-                return false;
-            });
+        defaultTemplateSource: tplHistoryPage,
+        events: {
+            "submit #diff-page": "_submitDiffPage"
+        },
+        _submitDiffPage: function(e) {
+            e.preventDefault();
+            this.model.doDiff(this);
+            return false;
         },
         titleFormat: function(title) {
             return 'History: ' + title;
@@ -300,50 +340,47 @@
     });
 
     var PageRevisionView = exports.PageRevisionView = MasterPageView.extend({
-        defaultTemplateName: 'revision-page',
+        defaultTemplateSource: tplRevisionPage,
         titleFormat: function(title) {
             return title + ' [' + this.model.get('rev') + ']';
         }
     });
 
     var PageDiffView = exports.PageDiffView = MasterPageView.extend({
-        defaultTemplateName: 'diff-page',
+        defaultTemplateSource: tplDiffPage,
         titleFormat: function(title) {
             return title + ' [' + this.model.get('rev1') + '-' + this.model.get('rev2') + ']';
         }
     });
 
     var IncomingLinksView = exports.IncomingLinksView = MasterPageView.extend({
-        defaultTemplateName: 'inlinks-page',
+        defaultTemplateSource: tplInLinksPage,
         titleFormat: function(title) {
             return 'Incoming Links: ' + title;
         }
     });
 
     var WikiSearchView = exports.WikiSearchView = MasterPageView.extend({
-        defaultTemplateName: 'search-results'
+        defaultTemplateSource: tplSearchResults
     });
 
     var SpecialNavigationView = exports.SpecialNavigationView = NavigationView.extend({
-        defaultTemplateName: 'special-nav'
+        templateSource: tplSpecialNav
     });
 
-    var SpecialPagesView = exports.SpecialPagesView = MasterPageView.extend({
-        defaultTemplateName: 'special-pages',
+    var SpecialMasterPageView = exports.SpecialMasterPageView = MasterPageView.extend({
         _createNavigation: function(model) {
-            model.set('show_root_link', false);
+            model.set('show_root_link', true);
             return new SpecialNavigationView({ model: model });
         }
     });
 
-    var GenericSpecialPageView = exports.GenericSpecialPageView = MasterPageView.extend({
-        defaultTemplateName: function() {
-            return 'special-' + this.model.get('page');
-        },
-        _createNavigation: function(model) {
-            model.set('show_root_link', true);
-            return new SpecialNavigationView({ model: model });
-        },
+    var SpecialPagesView = exports.SpecialPagesView = SpecialMasterPageView.extend({
+        defaultTemplateSource: tplSpecialPages
+    });
+
+    var SpecialChangesView = exports.SpecialChangesView = SpecialMasterPageView.extend({
+        defaultTemplateSource: tplSpecialChanges,
         _onModelChange: function() {
             var history = this.model.get('history');
             if (history) {
@@ -369,10 +406,14 @@
                 }
                 this.model.set('history', history);
             }
-            this.render();
+            SpecialChangesView.__super__._onModelChange.apply(this, arguments);
         }
     });
 
+    var SpecialOrphansView = exports.SpecialOrphansView = SpecialMasterPageView.extend({
+        defaultTemplateSource: tplSpecialOrphans
+    });
+
     return exports;
 });
 
--- a/wikked/static/tpl/inlinks-page.html	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/tpl/inlinks-page.html	Sat Jan 26 22:17:51 2013 -0800
@@ -8,7 +8,7 @@
                 {{#if missing}}
                 <a class="wiki-link missing" href="/#/edit/{{url}}">{{url}}</a>
                 {{else}}
-                <a class="wiki-link" href="/#/read/{{url}}">{{meta.title}}</a>
+                <a class="wiki-link" href="/#/read/{{url}}">{{title}}</a>
                 {{/if}}
             </li>
         {{/each}}
--- a/wikked/static/tpl/login.html	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/static/tpl/login.html	Sat Jan 26 22:17:51 2013 -0800
@@ -1,6 +1,16 @@
+<nav class="row">
+    <div class="span12">
+        <a href="/">Home</a>
+    </div>
+</nav>
 <div class="row">
     <div class="page span12">
         <h1>Login</h1>
+        {{#if has_error}}
+        <div class="alert alert-error">
+            <strong>Begone!</strong> Those credentials don't seem to work here.
+        </div>
+        {{/if}}
         <form id="login">
             <div class="control-group input-prepend">
                 <label for="username" class="control-label add-on">Username: </label>
@@ -19,3 +29,5 @@
         </form>
     </div>
 </div>
+<div class="row meta">
+</div>
\ No newline at end of file
--- a/wikked/templates/index.html	Fri Jan 25 22:35:56 2013 -0800
+++ b/wikked/templates/index.html	Sat Jan 26 22:17:51 2013 -0800
@@ -15,6 +15,6 @@
                 deps: ["/js/wikked.js{{cache_bust}}"]
             };
         </script>
-        <script src="/js/require.js{{cache_bust}}"></script>
+        <script src="/js/require.js"></script>
     </body>
 </html>