changeset 655:9c0092fae31d

Merge pull request #26 from GitHub.
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 22 Feb 2016 22:50:30 -0800
parents 466bbddd121e (diff) b1c4c9e0c65f (current diff)
children dba53f0f7671
files
diffstat 132 files changed, 4600 insertions(+), 1171 deletions(-) [+]
line wrap: on
line diff
--- a/.ctrlpignore	Wed Dec 30 20:21:41 2015 +1300
+++ b/.ctrlpignore	Mon Feb 22 22:50:30 2016 -0800
@@ -1,2 +1,4 @@
 dir:build/lib
+bower_components
+node_modules
 
--- a/.gutctags	Wed Dec 30 20:21:41 2015 +1300
+++ b/.gutctags	Mon Feb 22 22:50:30 2016 -0800
@@ -2,8 +2,8 @@
 --exclude=dist
 --exclude=docs/_cache
 --exclude=docs/_counter
---exclude=docs/bower_components
---exclude=docs/node_modules
+--exclude=bower_components
+--exclude=node_modules
 --exclude=docs/raw
 --exclude=*.egg-info
 --exclude=__pycache__
--- a/.hgignore	Wed Dec 30 20:21:41 2015 +1300
+++ b/.hgignore	Mon Feb 22 22:50:30 2016 -0800
@@ -1,16 +1,18 @@
 syntax: glob
 *.pyc
 *.egg-info
+__pycache__
 venv
 tags
+bower_components
+node_modules
 build/lib
 util/messages/_cache
 util/messages/_counter
 dist
 docs/_cache
 docs/_counter
-docs/bower_components
-docs/node_modules
+foodtruck/static
 piecrust.egg-info
 piecrust/__version__.py
 .ropeproject
--- a/.hgtags	Wed Dec 30 20:21:41 2015 +1300
+++ b/.hgtags	Mon Feb 22 22:50:30 2016 -0800
@@ -20,3 +20,5 @@
 c3c1171679de13432c3d2ce08f7a29cef0f35423 2.0.0b1
 03c3a77fda60d28df6d5f3592ed4468b4869bdea 2.0.0b2
 6ef89b31dddaf50d8cbcb8ee6603e4530ed4d64c 2.0.0b3
+14eec6faf10bad9e539bfb92c2984155412846a7 2.0.0b4
+cc2d212c3ba14af0ac8c38ce3c2764ed7db6f1fe 2.0.0b5
--- a/CHANGELOG.rst	Wed Dec 30 20:21:41 2015 +1300
+++ b/CHANGELOG.rst	Mon Feb 22 22:50:30 2016 -0800
@@ -10,7 +10,169 @@
 
 
 ==================================
-1. PieCrust 2.0.0b2 (2015-07-29)
+1. PieCrust 2.0.0b5 (2016-02-16)
+==================================
+
+
+1.0 Commands
+----------------------
+
+* admin: Remove settings view.
+* admin: Don't require ``bcrypt`` for running FoodTruck with ``chef`` .
+
+1.1 Core
+----------------------
+
+* internal: Remove SyntaxWarning from MacOS wrappers.
+
+1.2 Project
+----------------------
+
+* cm: Fix CHANGELOG newlines on Windows.
+* cm: Update npm modules and bower packages before making a release.
+* cm: Fixes and tweaks to the documentation generation task.
+* cm: Update node module versions.
+* cm: Update the node modules before building the documentation.
+* cm: Get a new version of pytest-cov to avoid a random multiprocessing bug.
+* cm: Ignore more things for pytest.
+* cm: Move all scripts into a ``garcon`` package with ``invoke`` support.
+* cm: Exclude the correct directories from vim-gutentags.
+* cm: Fix categorization of CHANGELOG entries for new commands.
+* cm: Regenerate the CHANGELOG.
+
+==================================
+2. PieCrust 2.0.0b4 (2016-02-09)
+==================================
+
+
+1.0 Commands
+----------------------
+
+* chef: Fix the ``--config-set`` option.
+* admin: Make the publish UI handle new kinds of target configurations.
+* admin: Fix crashes when creating a new page.
+* admin: Fix responsive layout.
+* admin: Use ``HGPLAIN`` for the Mercurial VCS provider.
+* publish: Add option to change the source for the ``rsync`` publisher.
+* publish: Change the ``shell`` config setting name for the command to run.
+* publish: Add the ``rsync`` publisher.
+* publish: Polish/refactor the publishing workflows.
+* admin: Make the sidebar togglable for smaller screens.
+* admin: Change the default admin server port to 8090, add ``--port`` option.
+* admin: Improve publish logs showing as alerts in the admin panel.
+* publish: Make the ``shell`` log update faster by flushing the pipe.
+* publish: Add publish command.
+* chef: Add ``--pid-file`` option.
+* admin: Use the app directory, not the cwd, in case of ``--root`` .
+* admin: Configuration changes.
+* admin: Fix "Publish started" message showing up multiple times.
+* admin: Show the install page if no secret key is available.
+* admin: Prompt the user for a commit message when committing a page.
+* admin: Fix creating pages.
+* admin: Better UI for publishing websites.
+* admin: Better error reporting, general clean-up.
+* admin: Fix constructor for Mercurial SCM.
+* admin: Set the ``DEBUG`` flag before the app runs so we can read it during setup.
+* admin: Ability to configure SCM stuff per site.
+* admin: Better production config for FoodTruck, provide proper first site.
+* admin: Make sure we have a valid default site to start with.
+* admin: Dashboard UI cleaning, re-use utility function for page summaries.
+* admin: Add summary of page in source listing.
+* admin: New ``admin`` command to manage FoodTruck-related things.
+* admin: Add "FoodTruck" admin panel from the side experiment project.
+* bake: Add new performance timers.
+* bake: Add support for a "known" page setting that excludes it from the bake.
+* bake: Add option to bake assets for FoodTruck. This is likely temporary.
+* sources: Add method to get a page factory from a path.
+* sources: Add code to support "interactive" metadata acquisition.
+* serve: Make it possible to preview pages with a custom root URL.
+* serve: Fix corner cases where the pipeline doesn't run correctly.
+* showconfig: Don't crash when the whole config should be shown.
+* bake: Don't re-setup logging for workers unless we're sure we need it.
+* serve: Fix error reporting when the background pipeline fails.
+* chef: Add ``--debug-only`` option to only show debug logging for a given logger.
+* routes: Add better support for taxonomy slugification.
+* serve: Improve reloading and shutdown of the preview server.
+* serve: Don't crash when looking at the debug info in a stand-alone window.
+* serve: Improve debug information in the preview server.
+* serve: Refactor the server to make pieces usable by the debugging middleware.
+* serve: Fix timing information in the debug window.
+* serve: Extract some of the server's functionality into WSGI middlewares.
+* serve: Rewrite of the Server-Sent Event code for build notifications.
+* serve: Werkzeug docs say you need to pass a flag with ``wrap_file`` .
+* bake: Add a flag to know which record entries got collapsed from last run.
+* bake: Set the flags, don't combine.
+
+1.1 Core
+----------------------
+
+* debug: Fix debug window CSS.
+* debug: Don't show parentheses on redirected properties.
+* debug: Fix how the linker shows children/siblings/etc. in the debug window.
+* internal: Some fixes to the new app configuration.
+* internal: Refactor the app configuration class.
+* cli: More proper argument parsing for the main/root arguments.
+* cli: Add ``--no-color`` option.
+* internal: Rename ``raw_content`` to ``segments`` since it's what it is.
+* bug: Fix a crash when some errors occur during page rendering.
+* data: Fix a crash bug when no parent page is set on an iterator.
+* bug: Correctly handle root URLs with special characters.
+* debug: Fix a crash when rendering debug info for some pages.
+
+1.2 Project
+----------------------
+
+* docs: Make FoodTruck screenshots the proper size.
+* cm: Add script to generate documentation.
+* docs: Add documentation about FoodTruck.
+* docs: Add raw files for FoodTruck screenshots.
+* docs: Add documentation about the ``publish`` command.
+* cm: Add some pretty little icons in the README.
+* tests: Add unicode tests for case-sensitive file-systems.
+* cm: Merge the 2 foodtruck folders, cleanup.
+* cm: Fix Gulp config.
+* docs: Fix broken link.
+* cm: Put Bower/Gulp/etc. stuff all at the root.
+* cm: Add requirements for FoodTruck.
+* cm: Ignore more stuff for CtrlP or Gutentags.
+* tests: Fix (hopefully) time-sensitive tests.
+* cm: CHANGELOG generator can handle future versions.
+* docs: Remove LessCSS dependencies in the tutorial, fix typos.
+* tests: Fix broken unit test.
+* tests: Fix another broken test.
+* docs: Add reference entry about the ``site/slugify_mode`` setting.
+* tests: Fix broken test.
+* tests: Print more information when a bake test fails to find an output file.
+
+==================================
+3. PieCrust 2.0.0b3 (2015-08-01)
+==================================
+
+
+1.0 Commands
+----------------------
+
+* import: Correctly convert unicode characters in site configuration.
+* import: Fix the PieCrust 1 importer.
+* import: Add some debug logging.
+
+1.1 Core
+----------------------
+
+* internal: Fix a severe bug with the file-system wrappers on OSX.
+* templating: Make more date functions accept 'now' as an input.
+
+1.2 Project
+----------------------
+
+* cm: Update changelog.
+* cm: Changelog generator script.
+* cm: Add a Gutentags config file for ``ctags`` generation.
+* tests: Check accented characters work in configurations.
+* cm: Ignore Rope cache.
+
+==================================
+4. PieCrust 2.0.0b2 (2015-07-29)
 ==================================
 
 
@@ -25,7 +187,7 @@
 * bug: Fix crash running ``chef help scaffolding`` outside of a website.
 
 ==================================
-2. PieCrust 2.0.0b1 (2015-07-29)
+5. PieCrust 2.0.0b1 (2015-07-29)
 ==================================
 
 
@@ -104,7 +266,7 @@
 * jinja: Support ``.j2`` file extensions.
 
 ==================================
-3. PieCrust 2.0.0a13 (2015-07-14)
+6. PieCrust 2.0.0a13 (2015-07-14)
 ==================================
 
 
@@ -120,7 +282,7 @@
 * bug: Correctly setup the environment/app for bake workers.
 
 ==================================
-4. PieCrust 2.0.0a12 (2015-07-14)
+7. PieCrust 2.0.0a12 (2015-07-14)
 ==================================
 
 
@@ -206,7 +368,7 @@
 * markdown: Cache the formatter once.
 
 ==================================
-5. PieCrust 2.0.0a11 (2015-05-18)
+8. PieCrust 2.0.0a11 (2015-05-18)
 ==================================
 
 
@@ -240,7 +402,7 @@
 * jinja: Look for ``html`` extension first instead of last.
 
 ==================================
-6. PieCrust 2.0.0a10 (2015-05-15)
+9. PieCrust 2.0.0a10 (2015-05-15)
 ==================================
 
 
@@ -250,7 +412,7 @@
 * setup: Add ``requirements.txt`` to ``MANIFEST.in`` so it can be used by the setup.
 
 ==================================
-7. PieCrust 2.0.0a9 (2015-05-11)
+10. PieCrust 2.0.0a9 (2015-05-11)
 ==================================
 
 
@@ -281,7 +443,7 @@
 * setup: Keep the requirements in sync between ``setuptools`` and ``pip`` .
 
 ==================================
-8. PieCrust 2.0.0a8 (2015-05-03)
+11. PieCrust 2.0.0a8 (2015-05-03)
 ==================================
 
 
@@ -315,7 +477,7 @@
 * Update ``requirements.txt`` .
 
 ==================================
-9. PieCrust 2.0.0a7 (2015-04-20)
+12. PieCrust 2.0.0a7 (2015-04-20)
 ==================================
 
 
@@ -366,7 +528,7 @@
 * cleancss: Fix stupid bug.
 
 ==================================
-10. PieCrust 2.0.0a6 (2015-03-30)
+13. PieCrust 2.0.0a6 (2015-03-30)
 ==================================
 
 
@@ -533,7 +695,7 @@
 * processing: Add more information to the pipeline record.
 
 ==================================
-11. PieCrust 2.0.0a5 (2015-01-03)
+14. PieCrust 2.0.0a5 (2015-01-03)
 ==================================
 
 
--- a/MANIFEST.in	Wed Dec 30 20:21:41 2015 +1300
+++ b/MANIFEST.in	Mon Feb 22 22:50:30 2016 -0800
@@ -3,6 +3,8 @@
 include LICENSE.rst
 include requirements.txt
 include dev-requirements.txt
+recursive-include foodtruck *.py *.html
+recursive-include foodtruck/static *
 recursive-include piecrust *.py mime.types
 recursive-include piecrust/resources *
 recursive-include tests *.py
--- a/README.rst	Wed Dec 30 20:21:41 2015 +1300
+++ b/README.rst	Mon Feb 22 22:50:30 2016 -0800
@@ -9,6 +9,20 @@
 .. _the official website: http://bolt80.com/piecrust/
 
 
+|pypi-version| |pypi-downloads| |build-status|
+
+.. |pypi-version| image:: https://img.shields.io/pypi/v/piecrust.svg
+   :target: https://pypi.python.org/pypi/piecrust
+   :alt: PyPI: the Python Package Index
+.. |pypi-downloads| image:: https://img.shields.io/pypi/dm/piecrust.svg
+   :target: https://pypi.python.org/pypi/piecrust
+   :alt: PyPI: the Python Package Index
+.. |build-status| image:: https://img.shields.io/travis/ludovicchabant/PieCrust2/master.svg
+   :target: https://travis-ci.org/ludovicchabant/PieCrust2
+   :alt: Travis CI: continuous integration status
+
+
+
 Quickstart
 ==========
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bower.json	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,21 @@
+{
+  "name": "PieCrust",
+  "description": "",
+  "version": "0.0.1",
+  "private": "true",
+  "authors": [
+    "Ludovic Chabant <ludovic@chabant.com>"
+  ],
+  "homepage": "http://bolt80.com/piecrust",
+  "ignore": [
+    "node_modules",
+    "bower_components"
+  ],
+  "dependencies": {
+    "bootstrap": "~3.3.5",
+    "bootstrap-sass": "~3.3.5",
+    "font-awesome": "fontawesome#~4.5.0",
+    "Ionicons": "ionicons#~2.0.1"
+  }
+}
+
--- a/dev-requirements.txt	Wed Dec 30 20:21:41 2015 +1300
+++ b/dev-requirements.txt	Mon Feb 22 22:50:30 2016 -0800
@@ -1,7 +1,8 @@
 cov-core==1.15.0
-coverage==3.7.1
+coverage==4.0.3
+invoke==0.12.2
 mock==1.0.1
-pytest==2.7.0
-pytest-cov==1.8.1
-pytest-mock==0.4.3
+pytest==2.8.7
+pytest-cov==2.2.1
+pytest-mock==0.10.1
 
--- a/docs/assets/css/piecrust.less	Wed Dec 30 20:21:41 2015 +1300
+++ b/docs/assets/css/piecrust.less	Mon Feb 22 22:50:30 2016 -0800
@@ -1,29 +1,31 @@
 // Imports
+@bootstrap-path: "../../../bower_components/bootstrap";
+
 // Core variables and mixins
-@import "../../bower_components/bootstrap/less/variables.less";
-@import "../../bower_components/bootstrap/less/mixins.less";
+@import "@{bootstrap-path}/less/variables.less";
+@import "@{bootstrap-path}/less/mixins.less";
 
 // Reset and dependencies
-@import "../../bower_components/bootstrap/less/normalize.less";
-@import "../../bower_components/bootstrap/less/print.less";
-@import "../../bower_components/bootstrap/less/glyphicons.less";
+@import "@{bootstrap-path}/less/normalize.less";
+@import "@{bootstrap-path}/less/print.less";
+@import "@{bootstrap-path}/less/glyphicons.less";
 
 // Core CSS
-@import "../../bower_components/bootstrap/less/scaffolding.less";
-@import "../../bower_components/bootstrap/less/type.less";
-@import "../../bower_components/bootstrap/less/code.less";
-@import "../../bower_components/bootstrap/less/grid.less";
-@import "../../bower_components/bootstrap/less/forms.less";
-@import "../../bower_components/bootstrap/less/buttons.less";
+@import "@{bootstrap-path}/less/scaffolding.less";
+@import "@{bootstrap-path}/less/type.less";
+@import "@{bootstrap-path}/less/code.less";
+@import "@{bootstrap-path}/less/grid.less";
+@import "@{bootstrap-path}/less/forms.less";
+@import "@{bootstrap-path}/less/buttons.less";
 
 // Components
-@import "../../bower_components/bootstrap/less/component-animations.less";
-@import "../../bower_components/bootstrap/less/navs.less";
-@import "../../bower_components/bootstrap/less/navbar.less";
+@import "@{bootstrap-path}/less/component-animations.less";
+@import "@{bootstrap-path}/less/navs.less";
+@import "@{bootstrap-path}/less/navbar.less";
 
 // Utility classes
-@import "../../bower_components/bootstrap/less/utilities.less";
-@import "../../bower_components/bootstrap/less/responsive-utilities.less";
+@import "@{bootstrap-path}/less/utilities.less";
+@import "@{bootstrap-path}/less/responsive-utilities.less";
 
 
 // Variables
--- a/docs/assets/js/piecrust.js.concat	Wed Dec 30 20:21:41 2015 +1300
+++ b/docs/assets/js/piecrust.js.concat	Mon Feb 22 22:50:30 2016 -0800
@@ -1,7 +1,7 @@
 path_mode: absolute
 files:
-  - bower_components/jquery/dist/jquery.js
-  - bower_components/bootstrap/js/transition.js
-  - bower_components/bootstrap/js/collapse.js
-  - bower_components/bootstrap/js/scrollspy.js
+  - ../bower_components/jquery/dist/jquery.js
+  - ../bower_components/bootstrap/js/transition.js
+  - ../bower_components/bootstrap/js/collapse.js
+  - ../bower_components/bootstrap/js/scrollspy.js
 
--- a/docs/bower.json	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,8 +0,0 @@
-{
-  "name": "PieCrust",
-  "version": "0.0.1",
-  "private": "true",
-  "dependencies": {
-    "bootstrap": "~3.3.2"
-  }
-}
--- a/docs/docs/02_general/01_chef.md	Wed Dec 30 20:21:41 2015 +1300
+++ b/docs/docs/02_general/01_chef.md	Mon Feb 22 22:50:30 2016 -0800
@@ -28,7 +28,7 @@
   templates, layouts, assets -- into a self-contained static website that you
   can upload to your public server.
 
-[1]: {{docurl('general/website-structure')}}
+[1]: {{docurl('general/creating-websites')}}
 [2]: {{docurl('content/creating-pages')}}
 
 
--- a/docs/docs/10_publishing.md	Wed Dec 30 20:21:41 2015 +1300
+++ b/docs/docs/10_publishing.md	Mon Feb 22 22:50:30 2016 -0800
@@ -7,41 +7,82 @@
 > completely static website**. For how to deploy a PieCrust website as a dynamic
 > CMS, see the [deployment documentation][deploy].
 
+To publish your website, you need to "_bake_" it, and then place the output of
+this bake on a public server somewhere.
+
+
 ## Baking
 
-To publish your content as a static website, you need to "_bake_" it, _i.e._
-generate all the pages, posts, assets, and other pieces of content:
+When you "_bake_" your website, PieCrust will generate all the pages, posts,
+assets, and other pieces of content you made into simple files on disk. To do
+this, simply run:
 
     $ chef bake
 
-You should then see some information about how many pages PieCrust baked, how
-much time it took to do so, etc. Without any arguments, the output is located
-inside the `_counter` directory at the root of your website.
+You should see some information about how many pages PieCrust baked, how much
+time it took to do so, etc. Without any arguments, the output is placed inside
+the `_counter` directory at the root of your website.
 
 You can specify another output directory:
 
     $ chef bake -o /path/to/my/output
 
-For other parameters, refer to the help page for the `bake` command.
+For other parameters, refer to the help page for the `bake` command by running
+`chef help bake`.
 
-At this point, you only need to _publish_ it, _i.e._ copy or upload the output
-files (everything inside `_counter`, or whatever other output directory you
-specified) to a place where people will be able to access them. This is
-typically a public directory on machine that will serve it _via_ HTTP using
-software like [Apache][] or [Nginx][].
-
-
-[apache]: http://httpd.apache.org/
-[nginx]: http://nginx.org/
+At this point, you only need to copy or upload the output files (everything
+inside `_counter`, or whatever other output directory you specified) to a place
+where people will be able to access them. This is typically a public directory
+on machine that will serve it _via_ HTTP using software like [Apache][] or
+[Nginx][].
 
 
 ## Publishing
 
-At the moment, there are no publishing features included in PieCrust -- you just
-run `chef bake` as mentioned above directly on the server (pointing it to the
-public directory), or locally and then upload the output via (S)FTP.
+PieCrust is also capable of publishing your website more or less automatically
+for the most common types of setup. This is done with the `publish` command.
+
+You can specify various "_publish targets_" in your website configuration. By
+default, a target will first bake your website into a temporary directory, and
+then execute some steps that depend on the target type.
+
+For example, the following configuration has one target (`upload`) that runs
+`rsync` to copy the output of the bake to some web server:
+
+```
+publish:
+    upload:
+        type: rsync
+        destination: user@example.org:/home/user/www
+```
+
+You can then run:
 
-More publishing features will be included in the future.
+```
+$ chef publish upload
+Deploying to upload
+[   863.1 ms] baked 4 user pages.
+[     5.3 ms] baked 1 theme pages.
+[    79.2 ms] baked 0 taxonomy pages.
+[    84.8 ms] processed 3 assets.
+[  1210.9 ms] Baked website.
+building file list ... done
+
+sent 29965 bytes  received 20 bytes  19990.00 bytes/sec
+total size is 22189128  speedup is 740.01
+[   991.5 ms] Ran publisher rsync
+[  2203.3 ms] Deployed to upload
+```
+
+Of course, the output will vary based on your website, your target, whether you
+previously published that target or wiped the PieCrust cache, etc.
+
+For more information about the different types of publish targets available in
+PieCrust, refer to the [publishers reference][pubs].
+
 
 [deploy]: {{docurl('deploying')}}
+[pubs]: {{docurl('reference/publishers')}}
+[apache]: http://httpd.apache.org/
+[nginx]: http://nginx.org/
 
Binary file docs/docs/30_admin-panel-assets/commit.png has changed
Binary file docs/docs/30_admin-panel-assets/dashboard.png has changed
Binary file docs/docs/30_admin-panel-assets/edit.png has changed
Binary file docs/docs/30_admin-panel-assets/listsrc.png has changed
Binary file docs/docs/30_admin-panel-assets/publish.png has changed
Binary file docs/docs/30_admin-panel-assets/writenew.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/docs/30_admin-panel.md	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,77 @@
+---
+title: Administration Panel
+short_title: Admin Panel
+---
+
+Remember when we said there was no complex administration panel? Well, we kinda
+lied. Only not really, since the one that comes with PieCrust is super simple
+and, of course, completely optional. That's why we're only bringing it up here.
+
+![Administration Panel]({{assets.dashboard}})
+
+To run the administration panel, type:
+
+```
+$ chef admin run
+ * Running on http://localhost:8090/ (Press CTRL+C to quit)
+
+```
+
+Now copy paste the specified URL (`http://localhost:8090/`) into your favorite
+browser's address bar and you should see "FoodTruck", PieCrust's administrative
+panel.
+
+The navigation menu on the left should have:
+
+* The dashboard.
+* One entry for each of your page sources.
+* An interface to publish your website.
+
+## The Dashboard
+
+This is where you can see a summary of your website, with links to see your list
+of already existing content.
+
+If your website content is also stored in a version control system, you can see
+what's currently edited in the "_Work in Progress_" section.
+
+> Right now, FoodTruck only supports Mercurial for source control.
+
+
+## Page Sources
+
+For each source, you can list the existing pages:
+
+![Page Sources]({{assets.listsrc}})
+
+Clicking on any link will let you edit that page.
+
+You can also create a new page by clicking the appropriate entry in the left
+navigation menu. You'll have to fill up information similar to what you specify
+to the `chef prepare` command:
+
+![New Page]({{assets.writenew}})
+
+Once you created a page, or click a link for an existing one, you can edit the
+page:
+
+![Edit Page]({{assets.edit}})
+
+Note that if your website is stored in a VCS, you'll have the ability to commit
+the page file if you want, but using the dropdown on the "_Save_" button:
+
+![Commit Page]({{assets.commit}})
+
+
+## Publishing
+
+You also have a UI for running your publish targets. The descriptions shown for
+each target are taken from a `description` entry in their configuration
+settings.
+
+![Publish]({{assets.publish}})
+
+Clicking any "_Execute_" button will publish your website using the
+corresponding target. You'll see some notifications popup on the bottom right to
+indicate the progress of the operation.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/docs/99_reference/06_publishers.md	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,57 @@
+---
+title: "Appendix 6: Publishers"
+short_title: "Publishers"
+---
+
+Here's a list of website publishers that ship by default with PieCrust.
+
+Publishers are declared and configured in the website configuration like so:
+
+```
+publish:
+    <target_name>:
+        type: <publisher_type>
+        <config1>: <value1>
+        <config2>: <value2>
+```
+
+Note that apart for the `type` setting, all publishers also share a few common
+configuration settings:
+
+* `bake` (`true`): Unless set to `false`, PieCrust will first bake your website
+  into a temporary folder (`_cache/pub/<target_name>`). The publisher will then
+  by default pick it up from there.
+
+In addition to specifying publish targets via configuration settings, you can
+also (if you don't need anything fancy) specify some of them via a simple
+URL-like line:
+
+```
+publish:
+    <target_name>: <something://foo/bar>
+```
+
+The URL-like format is specified below on a per-publisher basis.
+
+
+## Shell Command
+
+This simple publisher runs the specified command from inside your website root
+directory.
+
+* `type`: `shell`.
+* `command`: The command to run.
+
+
+## Rsync
+
+This publisher will run `rsync` to copy or upload your website's bake output to
+a given destination.
+
+* `type`: `rsync`.
+* `destination`: The destination given to the `rsync` executable.
+* `source` (`_cache/pub/<target_name>`): The source given to the `rsync`
+  executable. It defaults to the automatic pre-publish bake output folder.
+* `options` (`-avc --delete`): The options to pass to the `rsync` executable. By
+  default, those will run `rsync` in "mirroring" mode.
+
Binary file docs/raw/foodtruck-commit.psd has changed
Binary file docs/raw/foodtruck-dashboard.psd has changed
Binary file docs/raw/foodtruck-edit.psd has changed
Binary file docs/raw/foodtruck-listsrc.psd has changed
Binary file docs/raw/foodtruck-publish.psd has changed
Binary file docs/raw/foodtruck-writenew.psd has changed
Binary file foodtruck/assets/img/foodtruck.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/assets/js/foodtruck.js	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,65 @@
+
+$(document).ready(function() {
+    $('.ft-nav-toggle').click(function() {
+        $('.ft-nav-container').toggleClass('ft-nav-enabled');
+        $('.ft-nav').toggleClass('ft-nav-enabled');
+    });
+
+    $('.ft-nav-collapsed + ul').hide();
+
+    $('#ft-commit-modal').on('shown.bs.modal', function () {
+        $('#ft-commit-msg').focus();
+    });
+
+    var publogEl = $('#ft-publog');
+    publogEl.mouseenter(function() {
+        publogEl.attr('data-autohide', 'false');
+    });
+    publogEl.on('hide', function() {
+        var containerEl = $('#ft-publog-container', publogEl);
+        containerEl.empty();
+    });
+
+    var closePublogBtn = $('button', publogEl);
+    closePublogBtn.on('click', function() {
+        publogEl.fadeOut(200);
+    });
+});
+
+var onPublishEvent = function(e) {
+
+    var publogEl = $('#ft-publog');
+    var containerEl = $('#ft-publog-container', publogEl);
+
+    var msgEl = $('<div>' + e.data + '</div>');
+    var removeMsgEl = function() {
+        msgEl.remove();
+        if (containerEl.children().length == 0) {
+            // Last message, hide the log window.
+            publogEl.fadeOut(200);
+        }
+    };
+    var timeoutId = window.setTimeout(function() {
+        if (publogEl.attr('data-autohide') == 'true') {
+            msgEl.fadeOut(400, removeMsgEl);
+        }
+    }, 4000);
+
+    if (containerEl.children().length == 0) {
+        // First message, show the log window, reset the mouseover marker.
+        publogEl.attr('data-autohide', 'true');
+        publogEl.fadeIn(200);
+    }
+    containerEl.append(msgEl);
+};
+
+if (!!window.EventSource) {
+    var source = new EventSource('/publish-log');
+    source.onerror = function(e) {
+        console.log("Error with SSE, closing.", e);
+        source.close();
+    };
+    source.addEventListener('message', onPublishEvent);
+}
+
+
Binary file foodtruck/assets/raw/foodtruck.psd has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/assets/sass/foodtruck.scss	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,44 @@
+
+// Overrides
+$icon-font-path: '../fonts/';
+
+// Core variables and mixins
+@import "bootstrap/variables";
+@import "bootstrap/mixins";
+
+// Reset and dependencies
+@import "bootstrap/normalize";
+@import "bootstrap/print";
+@import "bootstrap/glyphicons";
+
+// Core CSS
+@import "bootstrap/scaffolding";
+@import "bootstrap/type";
+@import "bootstrap/code";
+@import "bootstrap/grid";
+@import "bootstrap/tables";
+@import "bootstrap/forms";
+@import "bootstrap/buttons";
+
+// Components
+@import "bootstrap/alerts";
+@import "bootstrap/button-groups";
+@import "bootstrap/close";
+@import "bootstrap/component-animations";
+@import "bootstrap/dropdowns";
+@import "bootstrap/input-groups";
+@import "bootstrap/modals";
+
+// Utility classes
+@import "bootstrap/utilities";
+@import "bootstrap/responsive-utilities";
+
+// Ionicons
+@import "ionicons";
+
+// Foodtruck
+@import "foodtruck/base";
+@import "foodtruck/sidebar";
+@import "foodtruck/editing";
+@import "foodtruck/publog";
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/assets/sass/foodtruck/_base.scss	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,47 @@
+
+$ft-color-gray-darkest:  #1A2226;
+$ft-color-gray-darker:   #1E282C;
+$ft-color-gray-dark:     #222D32;
+$ft-color-gray:          #2C3B41;
+$ft-color-gray-light:    #8AA4AF;
+$ft-color-gray-lighter:  #B8C7CE;
+$ft-color-white:         #FFF;
+$ft-color-black:         #000;
+$ft-color-red:           #D33939;
+$ft-color-blue:          #3C8DBC;
+$ft-color-yellow:        #C9C836;
+
+
+header h1, header.title {
+    text-align: center;
+}
+
+footer {
+    text-align: center;
+    font-size: 0.8em;
+    letter-spacing: -0.02em;
+    color: #777;
+    margin: 4em 2em 2em 2em;
+}
+
+h1, h2, h3, h4, h5, h6 {
+    margin-bottom: 1em;
+}
+
+.ft-login {
+    padding: 1em;
+    margin: 2em 0;
+    box-shadow: 0 5px 10px #ddd;
+    border: 1px solid #dcdcdc;
+}
+
+.ft-pagination {
+    text-align: center;
+    color: $ft-color-gray;
+    font-size: 2em;
+    margin: 1em 0;
+}
+.ft-pagination-a {
+    padding: 0 0.2em;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/assets/sass/foodtruck/_editing.scss	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,13 @@
+//
+// Page editing
+// --------------------------------------------------
+
+.ft-write-form textarea {
+    @include resizable(vertical);
+
+    outline: none;
+    overflow: auto;
+    font-family: 'Courier', 'Courier New', sans-serif;
+    padding: 1em;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/assets/sass/foodtruck/_publog.scss	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,26 @@
+
+#ft-publog {
+    position: fixed;
+    right: 0;
+    bottom: 0;
+    width: 42%;
+    min-width: 20em;
+    margin: 0.5em;
+    color: $ft-color-white;
+    background: $ft-color-blue;
+    border-radius: 0.5em;
+    box-shadow: 0 0 10px darken($ft-color-blue, 50%);
+
+    button {
+        padding: 0.2em 0.4em;
+    }
+}
+
+#ft-publog-container {
+    margin: 1em;
+
+    div {
+        margin: 0.1em;
+    }
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/assets/sass/foodtruck/_sidebar.scss	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,151 @@
+//
+// Sidebar navigation
+// --------------------------------------------------
+
+$ft-nav-width: 25rem;
+$ft-nav-margin: 2rem;
+
+// Layout
+.ft-nav-container {
+    padding: 2rem;
+    margin-top: 3em;
+    transition: padding-left 0.5s ease;
+    transition: margin 0.5s ease;
+}
+.ft-nav-container.ft-nav-enabled {
+    padding-left: $ft-nav-width + $ft-nav-margin;
+}
+
+.ft-nav-toggle {
+    display: block;
+    z-index: 1001;
+    position: fixed;
+    top: 0;
+    left: 0;
+    transition: all 0.5s ease;
+}
+
+.ft-nav {
+    z-index: 1000;
+    position: fixed;
+    height: 100%;
+    width: $ft-nav-width;
+    left: -$ft-nav-width;
+    top: 0;
+    bottom: 0;
+    overflow-y: auto;
+    transition: all 0.5s ease;
+}
+.ft-nav.ft-nav-enabled {
+    left: 0;
+}
+
+@media(min-width:768px) {
+    .ft-nav-toggle {
+        left: -3rem;
+        display: none;
+    }
+    .ft-nav-container {
+        padding-left: $ft-nav-width + $ft-nav-margin;
+        margin-top: 0;
+    }
+    .ft-nav {
+        left: 0;
+    }
+}
+
+// Style
+.ft-nav-toggle {
+    background: $ft-color-gray-darkest;
+
+    a {
+        display: block;
+        padding: 0.2em 0.8em;
+        font-family: 'Lobster', cursive;
+        font-size: 2em;
+    }
+    a, a:hover, a:active, a:visited {
+        color: #fff;
+        text-decoration: none;
+    }
+}
+
+.ft-nav {
+    background: $ft-color-gray-darkest;
+    color: #fff;
+
+    span.icon {
+        font-size: 1.5em;
+        margin-right: 0.4em;
+    }
+}
+.ft-nav ul {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.ft-nav ul li {
+    margin: 0;
+    padding: 0;
+}
+.ft-nav ul li a {
+    border-left: 5px solid transparent;
+    color: $ft-color-gray-light;
+    background: $ft-color-gray-dark;
+    padding: 1rem;
+    display: block;
+    letter-spacing: 0.05em;
+    text-transform: uppercase;
+
+    &:hover {
+        border-left: 5px solid $ft-color-blue;
+        color: $ft-color-white;
+        background: $ft-color-gray-darker;
+        text-decoration: none;
+    }
+}
+.ft-nav li>ul {
+}
+.ft-nav li>ul li a {
+    color: $ft-color-gray-lighter;
+    background: $ft-color-gray;
+    padding-left: 3em;
+    text-transform: none;
+
+    &:hover {
+        color: $ft-color-white;
+        background: $ft-color-gray;
+    }
+}
+.ft-nav ul li a.ft-nav-active {
+    border-left: 5px solid $ft-color-red;
+    color: $ft-color-white;
+    background: $ft-color-gray-darker;
+}
+.ft-nav li>ul li a.ft-nav-active {
+    color: $ft-color-white;
+    background: $ft-color-gray;
+}
+
+// Title/logo
+.ft-nav-title {
+    font-size: 2rem;
+    font-weight: bold;
+    text-align: center;
+    padding: 2rem 0;
+    margin: 0;
+}
+.ft-nav-brand {
+    font-family: 'Lobster', cursive;
+    font-size: 2em;
+    text-shadow: 2px 5px 0 $ft-color-gray;
+}
+
+// Footer
+.ft-nav-auth {
+    color: $ft-color-gray;
+    font-size: 0.8em;
+    text-align: center;
+    margin: 2em 0;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/bcryptfallback.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,47 @@
+import hashlib
+import logging
+
+
+print_warning = False
+logger = logging.getLogger(__name__)
+
+
+try:
+    from bcrypt import hashpw, gensalt
+except ImportError:
+    print_warning = True
+
+    def hashpw(password, *args, **kwargs):
+        return hashlib.sha512(password).hexdigest().encode('utf8')
+
+    def gensalt(*args, **kwargs):
+        return b''
+
+
+try:
+    from flask.ext.bcrypt import Bcrypt
+except ImportError:
+    print_warning = True
+
+    def generate_password_hash(password):
+        return hashlib.sha512(password.encode('utf8')).hexdigest()
+
+    def check_password_hash(reference, check):
+        check_hash = hashlib.sha512(check.encode('utf8')).hexdigest()
+        return check_hash == reference
+
+    class SHA512Fallback(object):
+        is_fallback_bcrypt = True
+
+        def __init__(self, app=None):
+            self.generate_password_hash = generate_password_hash
+            self.check_password_hash = check_password_hash
+
+    Bcrypt = SHA512Fallback
+
+
+if print_warning:
+    logging.warning("Bcrypt not available... falling back to SHA512.")
+    logging.warning("Run `pip install Flask-Bcrypt` for more secure "
+                    "password hashing.")
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/configuration.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,74 @@
+import os.path
+import copy
+import logging
+import yaml
+from piecrust.configuration import (
+        Configuration, ConfigurationError, ConfigurationLoader,
+        merge_dicts)
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_foodtruck_config(dirname=None, fallback_config=None):
+    dirname = dirname or os.getcwd()
+    cfg_path = os.path.join(dirname, 'foodtruck.yml')
+    return FoodTruckConfiguration(cfg_path, fallback_config)
+
+
+class FoodTruckConfigNotFoundError(Exception):
+    pass
+
+
+class FoodTruckConfiguration(Configuration):
+    def __init__(self, cfg_path, fallback_config=None):
+        super(FoodTruckConfiguration, self).__init__()
+        self.cfg_path = cfg_path
+        self.fallback_config = fallback_config
+
+    def _load(self):
+        try:
+            with open(self.cfg_path, 'r', encoding='utf-8') as fp:
+                values = yaml.load(
+                        fp.read(),
+                        Loader=ConfigurationLoader)
+
+            self._values = self._validateAll(values)
+        except OSError:
+            if self.fallback_config is None:
+                raise FoodTruckConfigNotFoundError()
+
+            logger.debug("No FoodTruck configuration found, using fallback "
+                         "configuration.")
+            self._values = copy.deepcopy(self.fallback_config)
+        except Exception as ex:
+            raise ConfigurationError(
+                    "Error loading configuration from: %s" %
+                    self.cfg_path) from ex
+
+    def _validateAll(self, values):
+        if values is None:
+            values = {}
+
+        values = merge_dicts(copy.deepcopy(default_configuration), values)
+
+        return values
+
+    def save(self):
+        with open(self.cfg_path, 'w', encoding='utf8') as fp:
+            self.cfg.write(fp)
+
+
+default_configuration = {
+        'triggers': {
+            'bake': 'chef bake'
+            },
+        'scm': {
+            'type': 'hg'
+            },
+        'security': {
+            'username': '',
+            'password': ''
+            }
+        }
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/main.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,23 @@
+import logging
+
+
+logger = logging.getLogger(__name__)
+
+
+def run_foodtruck(host=None, port=None, debug=False):
+    if debug:
+        import foodtruck.settings
+        foodtruck.settings.DEBUG = debug
+
+    from .web import app
+    try:
+        app.run(host=host, port=port, debug=debug, threaded=True)
+    except SystemExit:
+        # This is needed for Werkzeug's code reloader to be able to correctly
+        # shutdown the child process in order to restart it (otherwise, SSE
+        # generators will keep it alive).
+        from . import pubutil
+        logger.debug("Shutting down SSE generators from main...")
+        pubutil.server_shutdown = True
+        raise
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/pubutil.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,159 @@
+import os
+import os.path
+import time
+import errno
+import signal
+import logging
+from .web import app
+
+
+logger = logging.getLogger(__name__)
+
+server_shutdown = False
+
+
+def _shutdown_server_and_raise_sigint():
+    if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
+        # This is needed when hitting CTRL+C to shutdown the Werkzeug server,
+        # otherwise SSE generators will keep it alive.
+        logger.debug("Shutting down SSE generators...")
+        global server_shutdown
+        server_shutdown = True
+    raise KeyboardInterrupt()
+
+
+if app.config['FOODTRUCK_CMDLINE_MODE']:
+    # Make sure CTRL+C works correctly.
+    signal.signal(signal.SIGINT,
+                  lambda *args: _shutdown_server_and_raise_sigint())
+
+
+def _read_pid_file(pid_file):
+    logger.debug("Reading PID file: %s" % pid_file)
+    try:
+        with open(pid_file, 'r') as fp:
+            pid_str = fp.read()
+
+        return int(pid_str.strip())
+    except Exception:
+        logger.error("Error reading PID file.")
+        raise
+
+
+def _pid_exists(pid):
+    logger.debug("Checking if process ID %d is running" % pid)
+    try:
+        os.kill(pid, 0)
+    except OSError as ex:
+        if ex.errno == errno.ESRCH:
+            # No such process.
+            return False
+        elif ex.errno == errno.EPERM:
+            # No permission, so process exists.
+            return True
+        else:
+            raise
+    else:
+        return True
+
+
+class PublishLogReader(object):
+    _poll_interval = 1        # Check the process every 1 seconds.
+    _ping_interval = 30       # Send a ping message every 30 seconds.
+
+    def __init__(self, pid_path, log_path):
+        self.pid_path = pid_path
+        self.log_path = log_path
+
+    def run(self):
+        logger.debug("Opening publish log...")
+        pid = None
+        pid_mtime = 0
+        is_running = False
+        last_seek = -1
+        last_ping_time = 0
+        try:
+            while not server_shutdown:
+                # PING!
+                interval = time.time() - last_ping_time
+                if interval > self._ping_interval:
+                    logger.debug("Sending ping...")
+                    last_ping_time = time.time()
+                    yield bytes("event: ping\ndata: 1\n\n", 'utf8')
+
+                # Check the PID file timestamp.
+                try:
+                    new_mtime = os.path.getmtime(self.pid_path)
+                except OSError:
+                    new_mtime = 0
+
+                # If there's a valid PID file and we either just started
+                # streaming (pid_mtime == 0) or we remember an older version
+                # of that PID file (pid_mtime != new_mtime), let's read the
+                # PID from the file.
+                is_pid_file_prehistoric = False
+                if new_mtime > 0 and new_mtime != pid_mtime:
+                    is_pid_file_prehistoric = (pid_mtime == 0)
+                    pid_mtime = new_mtime
+                    pid = _read_pid_file(self.pid_path)
+
+                if is_pid_file_prehistoric:
+                    logger.debug("PID file is pre-historic, we will skip the "
+                                 "first parts of the log.")
+
+                # If we have a valid PID, let's check if the process is
+                # currently running.
+                was_running = is_running
+                if pid:
+                    is_running = _pid_exists(pid)
+                    logger.debug(
+                            "Process %d is %s" %
+                            (pid, 'running' if is_running else 'not running'))
+                    if not is_running:
+                        # Let's forget this PID file until it changes.
+                        pid = None
+                else:
+                    is_running = False
+
+                # Read new data from the log file.
+                new_data = None
+                if is_running or was_running:
+                    if last_seek < 0:
+                        # Only send the "publish started" message if we
+                        # actually caught the process as it was starting, not
+                        # if we started streaming after it started.
+                        # This means we saw the PID file get changed.
+                        if not is_pid_file_prehistoric:
+                            outstr = (
+                                    'event: message\n'
+                                    'data: Publish started.\n\n')
+                            yield bytes(outstr, 'utf8')
+                        last_seek = 0
+
+                    try:
+                        with open(self.log_path, 'r', encoding='utf8') as fp:
+                            fp.seek(last_seek)
+                            new_data = fp.read()
+                            last_seek = fp.tell()
+                    except OSError:
+                        pass
+                if not is_running:
+                    # Process is not running anymore, let's reset our seek
+                    # marker back to the beginning.
+                    last_seek = -1
+
+                # Stream the new data to the client, but don't send old stuff
+                # that happened before we started this stream.
+                if new_data and not is_pid_file_prehistoric:
+                    logger.debug("SSE: %s" % new_data)
+                    for line in new_data.split('\n'):
+                        outstr = 'event: message\ndata: %s\n\n' % line
+                        yield bytes(outstr, 'utf8')
+
+                time.sleep(self._poll_interval)
+
+        except GeneratorExit:
+            pass
+
+        logger.debug("Closing publish log...")
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/scm/base.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,23 @@
+
+
+class RepoStatus(object):
+    def __init__(self):
+        self.new_files = []
+        self.edited_files = []
+
+
+class SourceControl(object):
+    def __init__(self, root_dir, cfg):
+        self.root_dir = root_dir
+        self.config = cfg
+
+    def getStatus(self):
+        raise NotImplementedError()
+
+    def commit(self, paths, message, *, author=None):
+        author = author or self.config.get('author')
+        self._doCommit(paths, message, author)
+
+    def _doCommit(self, paths, message, author):
+        raise NotImplementedError()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/scm/mercurial.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,77 @@
+import os
+import logging
+import tempfile
+import subprocess
+from .base import SourceControl, RepoStatus
+
+
+logger = logging.getLogger(__name__)
+
+
+def _s(strs):
+    """ Convert a byte array to string using UTF8 encoding. """
+    if strs is None:
+        return None
+    assert isinstance(strs, bytes)
+    return strs.decode('utf8')
+
+
+class MercurialSourceControl(SourceControl):
+    def __init__(self, root_dir, cfg):
+        super(MercurialSourceControl, self).__init__(root_dir, cfg)
+        self.hg = cfg.get('exe', 'hg')
+
+    def getStatus(self):
+        res = RepoStatus()
+        st_out = self._run('status')
+        for line in st_out.split('\n'):
+            if len(line) == 0:
+                continue
+            if line[0] == '?' or line[0] == 'A':
+                res.new_files.append(line[2:])
+            elif line[0] == 'M':
+                res.edited_files.append(line[2:])
+        return res
+
+    def _doCommit(self, paths, message, author):
+        if not message:
+            raise ValueError("No commit message specified.")
+
+        # Check if any of those paths needs to be added.
+        st_out = self._run('status', *paths)
+        add_paths = []
+        for line in st_out.splitlines():
+            if line[0] == '?':
+                add_paths.append(line[2:])
+        if len(add_paths) > 0:
+            self._run('add', *paths)
+
+        # Create a temp file with the commit message.
+        f, temp = tempfile.mkstemp()
+        with os.fdopen(f, 'w') as fd:
+            fd.write(message)
+
+        # Commit and clean up the temp file.
+        try:
+            commit_args = list(paths) + ['-l', temp]
+            if author:
+                commit_args += ['-u', author]
+            self._run('commit', *commit_args)
+        finally:
+            os.remove(temp)
+
+    def _run(self, cmd, *args, **kwargs):
+        exe = [self.hg, '-R', self.root_dir]
+        exe.append(cmd)
+        exe += args
+
+        env = dict(os.environ)
+        env['HGPLAIN'] = 'True'
+
+        logger.debug("Running Mercurial: " + str(exe))
+        proc = subprocess.Popen(exe, stdout=subprocess.PIPE, env=env)
+        out, _ = proc.communicate()
+
+        encoded_out = _s(out)
+        return encoded_out
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/sites.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,110 @@
+import os
+import os.path
+import copy
+import logging
+import threading
+import subprocess
+from piecrust.app import PieCrust
+from piecrust.configuration import merge_dicts
+
+
+logger = logging.getLogger(__name__)
+
+
+class UnauthorizedSiteAccessError(Exception):
+    pass
+
+
+class InvalidSiteError(Exception):
+    pass
+
+
+class Site(object):
+    def __init__(self, name, root_dir, config):
+        self.name = name
+        self.root_dir = root_dir
+        self._global_config = config
+        self._piecrust_app = None
+        self._scm = None
+        logger.debug("Creating site object for %s" % self.name)
+
+    @property
+    def piecrust_app(self):
+        if self._piecrust_app is None:
+            s = PieCrust(self.root_dir)
+            s.config.set('site/root', '/site/%s/' % self.name)
+            self._piecrust_app = s
+        return self._piecrust_app
+
+    @property
+    def scm(self):
+        if self._scm is None:
+            cfg = copy.deepcopy(self._global_config.get('scm', {}))
+            merge_dicts(cfg, self.piecrust_app.config.get('scm', {}))
+
+            if os.path.isdir(os.path.join(self.root_dir, '.hg')):
+                from .scm.mercurial import MercurialSourceControl
+                self._scm = MercurialSourceControl(self.root_dir, cfg)
+            elif os.path.isdir(os.path.join(self.root_dir, '.git')):
+                from .scm.git import GitSourceControl
+                self._scm = GitSourceControl(self.root_dir, cfg)
+            else:
+                self._scm = False
+
+        return self._scm
+
+    @property
+    def publish_pid_file(self):
+        return os.path.join(self.piecrust_app.cache_dir, 'publish.pid')
+
+    @property
+    def publish_log_file(self):
+        return os.path.join(self.piecrust_app.cache_dir, 'publish.log')
+
+    def publish(self, target):
+        args = [
+                'chef',
+                '--pid-file', self.publish_pid_file,
+                'publish', target,
+                '--log-publisher', self.publish_log_file]
+        proc = subprocess.Popen(args, cwd=self.root_dir)
+
+        def _comm():
+            proc.communicate()
+
+        t = threading.Thread(target=_comm, daemon=True)
+        t.start()
+
+
+class FoodTruckSites():
+    def __init__(self, config, current_site):
+        self._sites = {}
+        self.config = config
+        self.current_site = current_site
+        if current_site is None:
+            raise Exception("No current site was given.")
+
+    def get_root_dir(self, name=None):
+        name = name or self.current_site
+        root_dir = self.config.get('sites/%s' % name)
+        if root_dir is None:
+            raise InvalidSiteError("No such site: %s" % name)
+        if not os.path.isdir(root_dir):
+            raise InvalidSiteError("Site '%s' has an invalid path." % name)
+        return root_dir
+
+    def get(self, name=None):
+        name = name or self.current_site
+        s = self._sites.get(name)
+        if s:
+            return s
+
+        root_dir = self.get_root_dir(name)
+        s = Site(name, root_dir, self.config)
+        self._sites[name] = s
+        return s
+
+    def getall(self):
+        for name in self.config.get('sites'):
+            yield self.get(name)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/create_page.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,33 @@
+{% set title = 'Create' %}
+
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<form action="{{url_postback}}" method="POST" class="ft-create-form">
+    {% for field in fields %}
+    <div class="row">
+        <div class="col-md-10 col-md-offset-1">
+            <div class="form-group">
+                <div class="input-group input-group-lg">
+                    <span class="input-group-addon" id="meta-{{field.name}}">{{field.display_name}}</span>
+                    <input type="text" class="form-control" value="{{field.value}}" aria-describedby="meta-{{field.name}}" name="meta-{{field.name}}" />
+                </div>
+            </div>
+        </div>
+    </div>
+
+    {% endfor %}
+
+    <input type="hidden" name="source_name" value="{{source_name}}" />
+
+    <div class="row">
+        <div class="col-md-6 col-md-offset-1">
+            <a class="btn btn-danger" href="{{url_cancel}}">Cancel</a>
+        </div>
+        <div class="col-md-4">
+            <button type="submit" name="do_save" class="btn btn-primary btn-lg pull-right">Create and Edit</button>
+        </div>
+    </div>
+</form>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/dashboard.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,60 @@
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<div class="row">
+    <div class="col-md-4 col-md-offset-8">
+    {% if needs_switch %}
+        <form action="{{url_switch}}" method="POST">
+        {% for site in sites %}
+            {% if site.name != site_name %}
+                <button type="submit" name="site_name" value="{{site.name}}" class="btn">
+                <span class="icon ion-shuffle"></span>
+                Switch to {{site.display_name}}</button>
+            {% endif %}
+        {% endfor %}
+        </form>
+    {% endif %}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        <h1>{{site_title}} <a href="{{url_preview}}"><span class="icon ion-arrow-right-c"></span></a></h1>
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-6">
+        <h2><span class="icon ion-stats-bars"></span> Site Summary</h2>
+        {% for s in sources %}
+        <div class="ft-summary-source">
+            <a href="{{s.list_url}}">{{s.page_count}} {{s.name}}</a>
+        </div>
+        {% endfor %}
+    </div>
+    <div class="col-md-6">
+        <h2><span class="icon ion-erlenmeyer-flask"></span> Work in Progress</h2>
+        {% if new_pages %}
+        <p>New pages</p>
+        <ul>
+            {% for p in new_pages %}
+            <li><a href="{{p.url}}">{{p.title}}</a><br/>
+                {%if p.text%}<pre>{{p.text}}</pre>{%endif%}</li>
+            {% endfor %}
+        </ul>
+        {% endif %}
+        {% if edited_pages %}
+        <p>Edited pages</p>
+        <ul>
+            {% for p in edited_pages %}
+            <li><a href="{{p.url}}">{{p.title}}</a><br/>
+                {%if p.text%}<pre>{{p.text}}</pre>{%endif%}</li>
+            {% endfor %}
+        </ul>
+        {% endif %}
+        {% if not new_pages and not edited_pages %}
+        <p>No work in progress.</p>
+        {% endif %}
+
+    </div>
+</div>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/edit_page.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,61 @@
+{% set title = 'Write' %}
+
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<form action="{{url_postback}}" method="POST" class="ft-write-form" id="ft-write-form">
+    <div class="row">
+        <div class="col-sm-10 col-sm-offset-1">
+            <div class="form-group">
+                <textarea name="page_text" class="form-control" placeholder="Post contents..." rows="20">{{page_text}}</textarea>
+            </div>
+        </div>
+    </div>
+
+    <input type="hidden" name="is_dos_nl" value="{{is_dos_nl}}" />
+
+    <div class="row">
+        <div class="col-sm-6 col-sm-offset-1 col-xs-8">
+            <button type="submit" formtarget="_blank" name="do_preview" class="btn btn-info">Preview</button>
+            <a class="btn btn-danger" href="{{url_cancel}}">Cancel</a>
+        </div>
+        <div class="col-sm-4 col-xs-4">
+            <div class="btn-group pull-right">
+                <button type="submit" name="do_save" class="btn btn-primary">Save</button>
+                <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="caret"></span>
+                    <span class="sr-only">Toggle Dropdown</span>
+                </button>
+                <ul class="dropdown-menu">
+                    <li><button type="button" class="btn btn-link" data-toggle="modal" data-target="#ft-commit-modal">Save and Commit</button></li>
+                </ul>
+            </div>
+        </div>
+    </div>
+
+    <div id="ft-commit-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="ft-commit-modal-label">
+        <div class="modal-dialog" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+                    <h4 class="modal-title" id="ft-commit-modal-label">Commit Page</h4>
+                </div>
+                <div class="modal-body">
+                    <p>This will commit the current page to your source control.</p>
+                    <div class="form-group">
+                        <div class="input-group">
+                            <span class="input-group-addon" id="ft-commit-msg-label">Message: </span>
+                            <input type="text" class="form-control" placeholder="{{commit_msg}}" aria-describedby="ft-commit-msg-label" name="commit_msg" id="ft-commit-msg" />
+                        </div>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+                    <button type="submit" class="btn btn-primary" name="do_save_and_commit">Save and Commit</button>
+                </div>
+            </div>
+        </div>
+    </div>
+</form>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/error.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,9 @@
+{% set title = 'An Error Occured' %}
+
+{% extends 'layouts/master.html' %}
+
+{% block content %}
+<p>{{error}}</p>
+{% endblock %}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/install.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,8 @@
+{% set title = 'Configuration File Missing' %}
+
+{% extends 'layouts/master.html' %}
+
+{% block content %}
+<p>No FoodTruck configuration file was found. Did you run <code>chef admin init</code>?</p>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/layouts/default.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,17 @@
+{% set wrapper_classes = 'ft-nav-container' %}
+
+{% extends 'layouts/master.html' %}
+
+{% block after_content %}
+<div class="ft-nav-toggle">
+    <a href="#">F</a>
+</div>
+<nav class="ft-nav">
+    <div class="ft-nav-title">
+        <img src="/static/img/foodtruck.png" alt="Food Truck" />
+        <div class="ft-nav-brand">FoodTruck</div>
+    </div>
+    {% include 'layouts/menu.html' %}
+</nav>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/layouts/master.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,43 @@
+<!doctype html>
+<html lang="">
+    <head>
+        <meta charset="utf-8"/>
+        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
+        <title>{%if title%}{{title}} &ndash; {%endif%}FoodTruck</title>
+        <meta name="description" content="A PieCrust management dashboard"/>
+        <meta name="viewport" content="width=device-width, initial-scale=1"/>
+        <link rel="apple-touch-icon" href="apple-touch-icon.png"/>
+        <link rel="stylesheet" href="/static/css/foodtruck.min.css"/>
+        <link href='https://fonts.googleapis.com/css?family=Lobster' rel='stylesheet' type='text/css'/>
+    </head>
+    <body>
+        <div id="ft-wrapper" class="container-fluid {{wrapper_classes}}">
+            <header>
+                {% if title %}<h1 class="title">{{title}}</h1>{% endif %}
+                {% block sub_header %}{% endblock %}
+            </header>
+            {% block before_content %}{% endblock %}
+            <section>
+                {% block content %}{% endblock %}
+            </section>
+            {% block after_content %}{% endblock %}
+            <div id="ft-publog" class="ft-publog" role="alert" style="display: none;">
+                <button type="button" class="close" aria-label="close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+                <div id="ft-publog-container"></div>
+            </div>
+            <footer>
+                <p>Prepared by <a href="http://bolt80.com">BOLT80</a>.</p>
+                <p>Much <span class="icon ion-heart"></span> to
+                <a href="http://python.org">Python</a>,
+                <a href="http://flask.pocoo.org">Flask</a>,
+                <a href="http://getbootstrap.com">Bootstrap</a>,
+                <a href="http://ionicons.com/">Ionicons</a>,
+                and many more.</p>
+            </footer>
+        </div>
+        <script src="/static/js/foodtruck.min.js"></script>
+    </body>
+</html>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/layouts/menu.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,17 @@
+<ul>
+    {%-for e in menu.entries%}
+        <li><a href="{{e.url}}" class="{%if e.active%}ft-nav-active{%elif e.entries and not e.open%}ft-nav-collapsed{%endif%}">
+        {%-if e.icon%}<span class="icon ion-{{e.icon}}"></span> {%endif%}{{e.title}}</a>
+        {%-if e.entries%}
+        <ul>
+            {%-for e2 in e.entries%}
+            <li><a href="{{e2.url}}" class="{%if e2.active%}ft-nav-active{%endif%}">{{e2.title}}</a></li>
+            {%endfor%}
+        </ul>
+        {%endif-%}
+    </li>
+    {%endfor%}
+</ul>
+{%if menu.user.is_authenticated%}
+<p class="ft-nav-auth">Logged in as {{menu.user.id}}. <a href="{{menu.url_logout}}">Logout</a>.</p>
+{%endif%}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/list_source.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,39 @@
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<div class="row">
+    <div class="col-md-12">
+        {% for p in pages %}
+        <div>
+            <h3><a href="{{p.url}}">{{p.title}}</a></h3>
+            <p>{{p.text}}</p>
+        </div>
+        {% endfor %}
+    </div>
+
+    {% if pagination.prev_page or pagination.next_page %}
+    <div class="col-sm-6 col-sm-offset-3">
+        <div class="ft-pagination">
+            {% if pagination.prev_page %}
+            <a href="{{pagination.prev_page}}"><span class="icon ion-chevron-left"></span> prev</a>
+            {% else %}
+            <span class="icon ion-chevron-left"></span> prev
+            {% endif %}
+
+            {% for p in pagination.nums %}
+            {% if p.url %}<a href="{{p.url}}" class="ft-pagination-a">{{p.num}}</a>{% else %}<span class="ft-pagination-a">{{p.num}}</span>{% endif %}
+            {% endfor %}
+
+            {% if pagination.next_page %}
+            <a href="{{pagination.next_page}}">next <span class="icon ion-chevron-right"></span></a>
+            {% else %}
+            next <span class="icon ion-chevron-right"></span>
+            {% endif %}
+        </div>
+    </div>
+    {% endif %}
+</div>
+
+{% endblock %}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/login.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,33 @@
+{% extends 'layouts/master.html' %}
+
+{% block content %}
+<div class="row">
+    <div class="col-md-6 col-md-offset-3">
+        <div class="ft-login">
+            <p class="login-box-msg">{{message|default('You know the drill.')}}</p>
+
+            <form action="{{login_postback}}" method="post">
+                <div class="form-group has-feedback">
+                    <input type="text" name="username" class="form-control" placeholder="Username">
+                    <span class="form-control-feedback"><i class="icon ion-log-in"></i></span>
+                </div>
+                <div class="form-group has-feedback">
+                    <input type="password" name="password" class="form-control" placeholder="Password">
+                    <span class="form-control-feedback"><i class="icon ion-locked"></i></span>
+                </div>
+                <div class="row">
+                    <div class="col-xs-8">
+                        <div class="checkbox">
+                            <label><input type="checkbox"> Remember Me</input></label>
+                        </div>
+                    </div>
+                    <div class="col-xs-4">
+                        <button type="submit" class="btn btn-primary btn-block">Log In</button>
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/publish.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,18 @@
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<h1>Publish {{site_title}}</h1>
+
+{% for target in targets %}
+<div>
+    <h3>{{target.name}}</h3>
+    {% if target.description %}<div>{{target.description}}</div>{% endif %}
+    <form action="{{url_run}}" method="POST">
+        <input type="hidden" name="target" value="{{target.name}}" />
+        <button type="submit" class="btn btn-default">Execute</button>
+    </form>
+</div>
+{% endfor %}
+
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/textutil.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,41 @@
+from html.parser import HTMLParser
+
+
+def text_preview(txt, length=100, *, max_length=None, offset=0):
+    max_length = max_length or (length + 50)
+    extract = txt[offset:offset + length]
+    if len(txt) > offset + length:
+        for i in range(offset + length,
+                       min(offset + max_length, len(txt))):
+            c = txt[i]
+            if c not in [' ', '\t', '\r', '\n']:
+                extract += c
+            else:
+                extract += '...'
+                break
+    return extract
+
+
+class MLStripper(HTMLParser):
+    def __init__(self):
+        super(MLStripper, self).__init__()
+        self.reset()
+        self.strict = False
+        self.convert_charrefs = True
+        self.fed = []
+
+    def handle_data(self, d):
+        self.fed.append(d)
+
+    def handle_entityref(self, name):
+        self.fed.append('&%s;' % name)
+
+    def get_data(self):
+        return ''.join(self.fed)
+
+
+def html_to_text(html):
+    s = MLStripper()
+    s.feed(html)
+    return s.get_data()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/__init__.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,28 @@
+from flask import render_template
+from flask.views import View
+from .menu import get_menu_context
+
+
+class FoodTruckView(View):
+    template_name = 'index.html'
+    requires_menu = True
+
+    def render_template(self, context):
+        if self.requires_menu:
+            context = with_menu_context()
+        return render_template(self.template_name, **context)
+
+    def get_context(self):
+        return None
+
+    def dispatch_request(self):
+        ctx = self.get_context()
+        return render_template(ctx)
+
+
+def with_menu_context(context=None):
+    if context is None:
+        context = {}
+    context['menu'] = get_menu_context()
+    return context
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/create.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,91 @@
+import os
+import os.path
+import logging
+from flask import (
+        g, request, abort, render_template, url_for, redirect, flash)
+from flask.ext.login import login_required
+from piecrust.sources.interfaces import IInteractiveSource
+from piecrust.sources.base import MODE_CREATING
+from piecrust.routing import create_route_metadata
+from ..views import with_menu_context
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/write/<source_name>', methods=['GET', 'POST'])
+@login_required
+def write_page(source_name):
+    site = g.site.piecrust_app
+    source = site.getSource(source_name)
+    if source is None:
+        abort(400)
+    if not isinstance(source, IInteractiveSource):
+        abort(400)
+
+    if request.method == 'POST':
+        if 'do_save' in request.form:
+            metadata = {}
+            for f in source.getInteractiveFields():
+                metadata[f.name] = f.default_value
+            for fk, fv in request.form.items():
+                if fk.startswith('meta-'):
+                    metadata[fk[5:]] = fv
+
+            logger.debug("Searching for page with metadata: %s" % metadata)
+            fac = source.findPageFactory(metadata, MODE_CREATING)
+            if fac is None:
+                logger.error("Can't find page for %s" % metadata)
+                abort(500)
+
+            logger.debug("Creating page: %s" % fac.path)
+            os.makedirs(os.path.dirname(fac.path), exist_ok=True)
+            with open(fac.path, 'w', encoding='utf8') as fp:
+                fp.write('')
+            flash("%s was created." % os.path.relpath(fac.path, site.root_dir))
+
+            route = site.getRoute(source.name, fac.metadata,
+                                  skip_taxonomies=True)
+            if route is None:
+                logger.error("Can't find route for page: %s" % fac.path)
+                abort(500)
+
+            dummy = _DummyPage(fac)
+            route_metadata = create_route_metadata(dummy)
+            uri = route.getUri(route_metadata)
+            uri_root = '/site/%s/' % g.site.name
+            uri = uri[len(uri_root):]
+            logger.debug("Redirecting to: %s" % uri)
+
+            return redirect(url_for('edit_page', slug=uri))
+
+        abort(400)
+
+    return _write_page_form(source)
+
+
+class _DummyPage:
+    def __init__(self, fac):
+        self.source_metadata = fac.metadata
+
+    def getRouteMetadata(self):
+        return {}
+
+
+def _write_page_form(source):
+    data = {}
+    data['is_new_page'] = True
+    data['source_name'] = source.name
+    data['url_postback'] = url_for('write_page', source_name=source.name)
+    data['fields'] = []
+    for f in source.getInteractiveFields():
+        data['fields'].append({
+            'name': f.name,
+            'display_name': f.name,
+            'type': f.field_type,
+            'value': f.default_value})
+
+    with_menu_context(data)
+    return render_template('create_page.html', **data)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/dashboard.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,143 @@
+import os
+import os.path
+import logging
+from flask import (
+        g, request,
+        render_template, url_for, redirect)
+from flask.ext.login import login_user, logout_user, login_required
+from piecrust.configuration import parse_config_header
+from piecrust.rendering import QualifiedPage
+from piecrust.uriutil import split_uri
+from ..textutil import text_preview
+from ..views import with_menu_context
+from ..web import app, load_user, after_this_request
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/')
+@login_required
+def index():
+    data = {}
+    data['sources'] = []
+    site = g.site
+    fs_endpoints = {}
+    for source in site.piecrust_app.sources:
+        if source.is_theme_source:
+            continue
+        facs = source.getPageFactories()
+        src_data = {
+                'name': source.name,
+                'list_url': url_for('list_source', source_name=source.name),
+                'page_count': len(facs)}
+        data['sources'].append(src_data)
+
+        fe = getattr(source, 'fs_endpoint', None)
+        if fe:
+            fs_endpoints[fe] = source
+
+    st = site.scm.getStatus()
+    data['new_pages'] = []
+    for p in st.new_files:
+        pd = _getWipData(p, site, fs_endpoints)
+        if pd:
+            data['new_pages'].append(pd)
+    data['edited_pages'] = []
+    for p in st.edited_files:
+        pd = _getWipData(p, site, fs_endpoints)
+        if pd:
+            data['edited_pages'].append(pd)
+
+    data['site_name'] = site.name
+    data['site_title'] = site.piecrust_app.config.get('site/title', site.name)
+    data['url_publish'] = url_for('publish')
+    data['url_preview'] = url_for('preview_site_root', sitename=site.name)
+
+    data['sites'] = []
+    for s in g.sites.getall():
+        data['sites'].append({
+            'name': s.name,
+            'display_name': s.piecrust_app.config.get('site/title'),
+            'url': url_for('index', site_name=s.name)
+            })
+    data['needs_switch'] = len(g.config.get('sites')) > 1
+    data['url_switch'] = url_for('switch_site')
+
+    with_menu_context(data)
+    return render_template('dashboard.html', **data)
+
+
+def _getWipData(path, site, fs_endpoints):
+    source = None
+    for endpoint, s in fs_endpoints.items():
+        if path.startswith(endpoint):
+            source = s
+            break
+    if source is None:
+        return None
+
+    fac = source.buildPageFactory(os.path.join(site.root_dir, path))
+    route = site.piecrust_app.getRoute(
+            source.name, fac.metadata, skip_taxonomies=True)
+    if not route:
+        return None
+
+    qp = QualifiedPage(fac.buildPage(), route, fac.metadata)
+    uri = qp.getUri()
+    _, slug = split_uri(site.piecrust_app, uri)
+
+    with open(fac.path, 'r', encoding='utf8') as fp:
+        raw_text = fp.read()
+
+    header, offset = parse_config_header(raw_text)
+    extract = text_preview(raw_text, offset=offset)
+    return {
+            'title': qp.config.get('title'),
+            'slug': slug,
+            'url': url_for('edit_page', slug=slug),
+            'text': extract
+            }
+
+
+@login_required
+@app.route('/switch_site', methods=['POST'])
+def switch_site():
+    site_name = request.form.get('site_name')
+    if not site_name:
+        return redirect(url_for('index'))
+
+    @after_this_request
+    def _save_site(resp):
+        resp.set_cookie('foodtruck_site_name', site_name)
+
+    return redirect(url_for('index'))
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    data = {}
+
+    if request.method == 'POST':
+        username = request.form.get('username')
+        password = request.form.get('password')
+        remember = request.form.get('remember')
+
+        user = load_user(username)
+        if user is not None and app.bcrypt:
+            if app.bcrypt.check_password_hash(user.password, password):
+                login_user(user, remember=bool(remember))
+                return redirect(url_for('index'))
+        data['message'] = (
+                "User '%s' doesn't exist or password is incorrect." %
+                username)
+
+    return render_template('login.html', **data)
+
+
+@app.route('/logout')
+@login_required
+def logout():
+    logout_user()
+    return redirect(url_for('index'))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/edit.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,75 @@
+import os.path
+import logging
+from flask import (
+        g, request, abort, render_template, url_for, flash)
+from flask.ext.login import login_required
+from piecrust.rendering import (
+        PageRenderingContext, render_page)
+from piecrust.serving.util import get_requested_page
+from ..views import with_menu_context
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/edit/', defaults={'slug': ''}, methods=['GET', 'POST'])
+@app.route('/edit/<path:slug>', methods=['GET', 'POST'])
+@login_required
+def edit_page(slug):
+    site = g.site
+    site_app = site.piecrust_app
+    rp = get_requested_page(site_app,
+                            '/site/%s/%s' % (g.sites.current_site, slug))
+    page = rp.qualified_page
+    if page is None:
+        abort(404)
+
+    if request.method == 'POST':
+        page_text = request.form['page_text']
+        if request.form['is_dos_nl'] == '0':
+            page_text = page_text.replace('\r\n', '\n')
+
+        if 'do_preview' in request.form or 'do_save' in request.form or \
+                'do_save_and_commit' in request.form:
+            logger.debug("Writing page: %s" % page.path)
+            with open(page.path, 'w', encoding='utf8') as fp:
+                fp.write(page_text)
+            flash("%s was saved." % os.path.relpath(
+                    page.path, site_app.root_dir))
+
+        if 'do_save_and_commit' in request.form:
+            message = request.form.get('commit_msg')
+            if not message:
+                message = "Edit %s" % os.path.relpath(
+                    page.path, site_app.root_dir)
+            site.scm.commit([page.path], message)
+
+        if 'do_preview' in request.form:
+            return _preview_page(page)
+
+        if 'do_save' in request.form or 'do_save_and_commit' in request.form:
+            return _edit_page_form(page)
+
+        abort(400)
+
+    return _edit_page_form(page)
+
+
+def _preview_page(page):
+    render_ctx = PageRenderingContext(page, force_render=True)
+    rp = render_page(render_ctx)
+    return rp.content
+
+
+def _edit_page_form(page):
+    data = {}
+    data['is_new_page'] = False
+    data['url_cancel'] = url_for('list_source', source_name=page.source.name)
+    with open(page.path, 'r', encoding='utf8') as fp:
+        data['page_text'] = fp.read()
+    data['is_dos_nl'] = "1" if '\r\n' in data['page_text'] else "0"
+
+    with_menu_context(data)
+    return render_template('edit_page.html', **data)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/menu.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,68 @@
+from flask import g, request, url_for
+from flask.ext.login import current_user
+
+
+def get_menu_context():
+    entries = []
+    entries.append({
+        'url': '/',
+        'title': "Dashboard",
+        'icon': 'speedometer'})
+
+    site = g.site.piecrust_app
+    for s in site.sources:
+        if s.is_theme_source:
+            continue
+
+        source_icon = s.config.get('admin_icon', 'document')
+        if s.name == 'pages':
+            source_icon = 'document-text'
+        elif 'blog' in s.name:
+            source_icon = 'filing'
+
+        url_write = url_for('write_page', source_name=s.name)
+        url_listall = url_for('list_source', source_name=s.name)
+
+        ctx = {
+                'url': url_listall,
+                'title': s.name,
+                'icon': source_icon,
+                'entries': [
+                    {'url': url_listall, 'title': "List All"},
+                    {'url': url_write, 'title': "Write New"}
+                    ]
+                }
+        entries.append(ctx)
+
+    entries.append({
+        'url': url_for('publish'),
+        'title': "Publish",
+        'icon': 'upload'})
+
+    # entries.append({
+    #     'url': url_for('settings'),
+    #     'title': "Settings",
+    #     'icon': 'gear-b'})
+
+    for e in entries:
+        needs_more_break = False
+        if 'entries' in e:
+            for e2 in e['entries']:
+                if e2['url'] == request.path:
+                    e['open'] = True
+                    e2['active'] = True
+                    needs_more_break = True
+                    break
+        if needs_more_break:
+            break
+
+        if e['url'] == request.path:
+            e['active'] = True
+            break
+
+    data = {'entries': entries,
+            'user': current_user,
+            'url_logout': url_for('logout')}
+    return data
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/preview.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,25 @@
+import os.path
+from flask import g, make_response
+from flask.ext.login import login_required
+from piecrust import CACHE_DIR
+from piecrust.serving.server import Server
+from ..web import app
+
+
+@app.route('/site/<sitename>/')
+@login_required
+def preview_site_root(sitename):
+    return preview_site(sitename, '/')
+
+
+@app.route('/site/<sitename>/<path:url>')
+@login_required
+def preview_site(sitename, url):
+    root_dir = g.sites.get_root_dir(sitename)
+    sub_cache_dir = os.path.join(root_dir, CACHE_DIR, 'foodtruck')
+    server = Server(root_dir, sub_cache_dir=sub_cache_dir,
+                    root_url='/site/%s/' % sitename,
+                    debug=app.debug)
+    return make_response(server._run_request)
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/publish.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,59 @@
+import copy
+import logging
+from flask import request, g, url_for, render_template, Response
+from flask.ext.login import login_required
+from ..pubutil import PublishLogReader
+from ..views import with_menu_context
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/publish', methods=['GET', 'POST'])
+@login_required
+def publish():
+    if request.method == 'POST':
+        target = request.form.get('target')
+        if not target:
+            raise Exception("No target specified.")
+
+        g.site.publish(target)
+
+    site = g.site
+    pub_cfg = copy.deepcopy(site.piecrust_app.config.get('publish', {}))
+    if not pub_cfg:
+        data = {'error': "There are not publish targets defined in your "
+                         "configuration file."}
+        return render_template('error.html', **data)
+
+    data = {}
+    data['url_run'] = url_for('publish')
+    data['site_title'] = site.piecrust_app.config.get('site/title', site.name)
+    data['targets'] = []
+    for tn in sorted(pub_cfg.keys()):
+        tc = pub_cfg[tn]
+        desc = None
+        if isinstance(tc, dict):
+            desc = tc.get('description')
+        data['targets'].append({
+            'name': tn,
+            'description': desc
+            })
+
+    with_menu_context(data)
+
+    return render_template('publish.html', **data)
+
+
+@app.route('/publish-log')
+@login_required
+def stream_publish_log():
+    pid_path = g.site.publish_pid_file
+    log_path = g.site.publish_log_file
+    rdr = PublishLogReader(pid_path, log_path)
+
+    response = Response(rdr.run(), mimetype='text/event-stream')
+    response.headers['Cache-Control'] = 'no-cache'
+    return response
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/sources.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,57 @@
+from flask import g, abort, render_template, url_for
+from flask.ext.login import login_required
+from piecrust.data.paginator import Paginator
+from ..textutil import text_preview, html_to_text
+from ..views import with_menu_context
+from ..web import app
+
+
+@app.route('/list/<source_name>/', defaults={'page_num': 1})
+@app.route('/list/<source_name>/<int:page_num>')
+@login_required
+def list_source(source_name, page_num):
+    site = g.site.piecrust_app
+    source = site.getSource(source_name)
+    if source is None:
+        abort(400)
+
+    i = 0
+    data = {'title': "List %s" % source_name}
+    data['pages'] = []
+    pgn = Paginator(None, source, page_num=page_num, items_per_page=20)
+    for p in pgn.items:
+        page_data = {
+                'title': p['title'],
+                'slug': p['slug'],
+                'source': source_name,
+                'url': url_for('edit_page', slug=p['slug']),
+                'text': text_preview(html_to_text(p['content']), length=300)}
+        data['pages'].append(page_data)
+
+    prev_page_url = None
+    if pgn.prev_page_number:
+        prev_page_url = url_for(
+                'list_source', source_name=source_name,
+                page_num=pgn.prev_page_number)
+    next_page_url = None
+    if pgn.next_page_number:
+        next_page_url = url_for(
+                'list_source', source_name=source_name,
+                page_num=pgn.next_page_number)
+
+    page_urls = []
+    for i in pgn.all_page_numbers(7):
+        url = None
+        if i != page_num:
+            url = url_for('list_source', source_name=source_name, page_num=i)
+        page_urls.append({'num': i, 'url': url})
+
+    data['pagination'] = {
+            'prev_page': prev_page_url,
+            'next_page': next_page_url,
+            'nums': page_urls
+            }
+
+    with_menu_context(data)
+    return render_template('list_source.html', **data)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/web.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,172 @@
+import os
+import os.path
+import logging
+from flask import Flask, g, request, render_template
+from .configuration import (
+        FoodTruckConfigNotFoundError, get_foodtruck_config)
+from .sites import FoodTruckSites, InvalidSiteError
+
+
+logger = logging.getLogger(__name__)
+
+app = Flask(__name__)
+app.config.from_object('foodtruck.settings')
+app.config.from_envvar('FOODTRUCK_SETTINGS', silent=True)
+
+admin_root = app.config['FOODTRUCK_ROOT'] or os.getcwd()
+config_path = os.path.join(admin_root, 'app.cfg')
+
+# If we're being run as the `chef admin run` command, from inside a PieCrust
+# website, do a few things differently.
+_procedural_config = None
+
+if (app.config['FOODTRUCK_CMDLINE_MODE'] and
+        os.path.isfile(os.path.join(admin_root, 'config.yml'))):
+    app.secret_key = os.urandom(22)
+    app.config['LOGIN_DISABLED'] = True
+    _procedural_config = {
+            'sites': {
+                'local': admin_root}
+            }
+
+
+if os.path.isfile(config_path):
+    app.config.from_pyfile(config_path)
+
+if app.config['DEBUG']:
+    l = logging.getLogger()
+    l.setLevel(logging.DEBUG)
+
+logger.debug("Using FoodTruck admin root: %s" % admin_root)
+
+
+def after_this_request(f):
+    if not hasattr(g, 'after_request_callbacks'):
+        g.after_request_callbacks = []
+    g.after_request_callbacks.append(f)
+    return f
+
+
+class LazySomething(object):
+    def __init__(self, factory):
+        self._factory = factory
+        self._something = None
+
+    def __getattr__(self, name):
+        if self._something is not None:
+            return getattr(self._something, name)
+
+        self._something = self._factory()
+        return getattr(self._something, name)
+
+
+@app.before_request
+def _setup_foodtruck_globals():
+    def _get_config():
+        return get_foodtruck_config(admin_root, _procedural_config)
+
+    def _get_sites():
+        names = g.config.get('sites')
+        if not names or not isinstance(names, dict):
+            raise InvalidSiteError(
+                    "No sites are defined in the configuration file.")
+
+        current = request.cookies.get('foodtruck_site_name')
+        if current is not None and current not in names:
+            current = None
+        if current is None:
+            current = next(iter(names.keys()))
+        s = FoodTruckSites(g.config, current)
+        return s
+
+    def _get_current_site():
+        return g.sites.get()
+
+    g.config = LazySomething(_get_config)
+    g.sites = LazySomething(_get_sites)
+    g.site = LazySomething(_get_current_site)
+
+
+@app.after_request
+def _call_after_request_callbacks(response):
+    for callback in getattr(g, 'after_request_callbacks', ()):
+        callback(response)
+    return response
+
+
+if not app.config['DEBUG']:
+    logger.debug("Registering exception handlers.")
+
+    @app.errorhandler(FoodTruckConfigNotFoundError)
+    def _on_config_missing(ex):
+        return render_template('install.html')
+
+    @app.errorhandler(InvalidSiteError)
+    def _on_invalid_site(ex):
+        data = {'error': "The was an error with your configuration file: %s" %
+                str(ex)}
+        return render_template('error.html', **data)
+
+
+@app.errorhandler
+def _on_error(ex):
+    logging.exception(ex)
+
+
+_missing_secret_key = False
+
+if not app.secret_key:
+    # If there's no secret key, create a temp one but mark the app as not
+    # correctly installed so it shows the installation information page.
+    app.secret_key = 'temp-key'
+    _missing_secret_key = True
+
+
+from flask.ext.login import LoginManager, UserMixin
+
+
+class User(UserMixin):
+    def __init__(self, uid, pwd):
+        self.id = uid
+        self.password = pwd
+
+
+def load_user(user_id):
+    admin_id = g.config.get('security/username')
+    if admin_id == user_id:
+        admin_pwd = g.config.get('security/password')
+        return User(admin_id, admin_pwd)
+    return None
+
+
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login'
+login_manager.user_loader(load_user)
+
+if _missing_secret_key:
+    def _handler():
+        raise FoodTruckConfigNotFoundError()
+
+    logger.debug("No secret key found, disabling website.")
+    login_manager.unauthorized_handler(_handler)
+    login_manager.login_view = None
+
+
+from foodtruck.bcryptfallback import Bcrypt
+if (getattr(Bcrypt, 'is_fallback_bcrypt', None) is True and
+        not app.config['FOODTRUCK_CMDLINE_MODE']):
+    raise Exception(
+            "You're running FoodTruck outside of `chef`, and will need to "
+            "install Flask-Bcrypt to get more proper security.")
+app.bcrypt = Bcrypt(app)
+
+
+import foodtruck.views.create  # NOQA
+import foodtruck.views.dashboard  # NOQA
+import foodtruck.views.edit  # NOQA
+import foodtruck.views.menu  # NOQA
+import foodtruck.views.preview  # NOQA
+import foodtruck.views.publish  # NOQA
+import foodtruck.views.sources  # NOQA
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/wsgiutil.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,23 @@
+import logging
+
+
+logger = logging.getLogger()
+
+
+def get_wsgi_app(admin_root=None, log_file=None,
+                 max_log_bytes=4096, log_backup_count=0,
+                 log_level=logging.INFO):
+    if log_file:
+        from logging.handlers import RotatingFileHandler
+        handler = RotatingFileHandler(log_file, maxBytes=max_log_bytes,
+                                      backupCount=log_backup_count)
+        handler.setLevel(log_level)
+        logging.getLogger().addHandler(handler)
+
+    logger.debug("Creating WSGI application.")
+    if admin_root:
+        import foodtruck.settings
+        foodtruck.settings.FOODTRUCK_ROOT = admin_root
+    from foodtruck.web import app
+    return app
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/benchsite.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,227 @@
+import io
+import os
+import os.path
+import string
+import random
+import datetime
+import argparse
+
+
+def generateWord(min_len=1, max_len=10):
+    length = random.randint(min_len, max_len)
+    word = ''.join(random.choice(string.ascii_letters) for _ in range(length))
+    return word
+
+
+def generateSentence(words):
+    return ' '.join([generateWord() for i in range(words)])
+
+
+def generateDate():
+    year = random.choice(range(1995, 2015))
+    month = random.choice(range(1, 13))
+    day = random.choice(range(1, 29))
+    hours = random.choice(range(0, 24))
+    minutes = random.choice(range(0, 60))
+    seconds = random.choice(range(0, 60))
+    return datetime.datetime(
+            year, month, day, hours, minutes, seconds)
+
+
+def generateTitleAndSlug():
+    title = generateSentence(8)
+    slug = title.replace(' ', '-').lower()
+    slug = ''.join(c for c in slug if c.isalnum() or c == '-')
+    return title, slug
+
+
+class BenchmarkSiteGenerator(object):
+    def __init__(self, out_dir):
+        self.out_dir = out_dir
+        self.all_tags = []
+
+    def generatePost(self):
+        post_info = {}
+        title, slug = generateTitleAndSlug()
+        post_info.update({
+            'title': title,
+            'slug': slug})
+        post_info['description'] = generateSentence(20)
+        post_info['tags'] = random.choice(self.all_tags)
+        post_info['datetime'] = generateDate()
+
+        buf = io.StringIO()
+        with buf:
+            para_count = random.randint(5, 10)
+            for i in range(para_count):
+                buf.write(generateSentence(random.randint(50, 100)))
+                buf.write('\n\n')
+            post_info['text'] = buf.getvalue()
+
+        self.writePost(post_info)
+
+    def initialize(self):
+        pass
+
+    def writePost(self, post_info):
+        raise NotImplementedError()
+
+
+class PieCrustBechmarkSiteGenerator(BenchmarkSiteGenerator):
+    def initialize(self):
+        posts_dir = os.path.join(self.out_dir, 'posts')
+        if not os.path.isdir(posts_dir):
+            os.makedirs(posts_dir)
+
+    def writePost(self, post_info):
+        out_dir = os.path.join(self.out_dir, 'posts')
+        slug = post_info['slug']
+        dtstr = post_info['datetime'].strftime('%Y-%m-%d')
+        with open('%s/%s_%s.md' % (out_dir, dtstr, slug), 'w',
+                  encoding='utf8') as f:
+            f.write('---\n')
+            f.write('title: %s\n' % post_info['title'])
+            f.write('description: %s\n' % post_info['description'])
+            f.write('tags: [%s]\n' % post_info['tags'])
+            f.write('---\n')
+
+            para_count = random.randint(5, 10)
+            for i in range(para_count):
+                f.write(generateSentence(random.randint(50, 100)))
+                f.write('\n\n')
+
+
+class OctopressBenchmarkSiteGenerator(BenchmarkSiteGenerator):
+    def initialize(self):
+        posts_dir = os.path.join(self.out_dir, 'source', '_posts')
+        if not os.path.isdir(posts_dir):
+            os.makedirs(posts_dir)
+
+    def writePost(self, post_info):
+        out_dir = os.path.join(self.out_dir, 'source', '_posts')
+        slug = post_info['slug']
+        dtstr = post_info['datetime'].strftime('%Y-%m-%d')
+        with open('%s/%s-%s.markdown' % (out_dir, dtstr, slug), 'w',
+                  encoding='utf8') as f:
+            f.write('---\n')
+            f.write('layout: post\n')
+            f.write('title: %s\n' % post_info['title'])
+            f.write('date: %s 12:00\n' % dtstr)
+            f.write('comments: false\n')
+            f.write('categories: [%s]\n' % post_info['tags'])
+            f.write('---\n')
+
+            para_count = random.randint(5, 10)
+            for i in range(para_count):
+                f.write(generateSentence(random.randint(50, 100)))
+                f.write('\n\n')
+
+
+class MiddlemanBenchmarkSiteGenerator(BenchmarkSiteGenerator):
+    def initialize(self):
+        posts_dir = os.path.join(self.out_dir, 'source')
+        if not os.path.isdir(posts_dir):
+            os.makedirs(posts_dir)
+
+    def writePost(self, post_info):
+        out_dir = os.path.join(self.out_dir, 'source')
+        slug = post_info['slug']
+        dtstr = post_info['datetime'].strftime('%Y-%m-%d')
+        with open('%s/%s-%s.html.markdown' % (out_dir, dtstr, slug), 'w',
+                  encoding='utf8') as f:
+            f.write('---\n')
+            f.write('title: %s\n' % post_info['title'])
+            f.write('date: %s\n' % post_info['datetime'].strftime('%Y/%m/%d'))
+            f.write('tags: %s\n' % post_info['tags'])
+            f.write('---\n')
+
+            para_count = random.randint(5, 10)
+            for i in range(para_count):
+                f.write(generateSentence(random.randint(50, 100)))
+                f.write('\n\n')
+
+
+class HugoBenchmarkSiteGenerator(BenchmarkSiteGenerator):
+    def initialize(self):
+        posts_dir = os.path.join(self.out_dir, 'content', 'post')
+        if not os.path.isdir(posts_dir):
+            os.makedirs(posts_dir)
+
+    def writePost(self, post_info):
+        out_dir = os.path.join(self.out_dir, 'content', 'post')
+        dtstr = post_info['datetime'].strftime('%Y-%m-%d_%H-%M-%S')
+        post_path = os.path.join(out_dir, '%s.md' % dtstr)
+        with open(post_path, 'w', encoding='utf8') as f:
+            f.write('+++\n')
+            f.write('title = "%s"\n' % post_info['title'])
+            f.write('description = "%s"\n' % post_info['description'])
+            f.write('categories = [\n  "%s"\n]\n' % post_info['tags'])
+            f.write('date = "%s"\n' % post_info['datetime'].strftime(
+                    "%Y-%m-%d %H:%M:%S-00:00"))
+            f.write('slug ="%s"\n' % post_info['slug'])
+            f.write('+++\n')
+            f.write(post_info['text'])
+
+
+generators = {
+        'piecrust': PieCrustBechmarkSiteGenerator,
+        'octopress': OctopressBenchmarkSiteGenerator,
+        'middleman': MiddlemanBenchmarkSiteGenerator,
+        'hugo': HugoBenchmarkSiteGenerator
+        }
+
+
+def main():
+    parser = argparse.ArgumentParser(
+            prog='generate_benchsite',
+            description=("Generates a benchmark website with placeholder "
+                         "content suitable for testing."))
+    parser.add_argument(
+            'engine',
+            help="The engine to generate the site for.",
+            choices=list(generators.keys()))
+    parser.add_argument(
+            'out_dir',
+            help="The target directory for the website.")
+    parser.add_argument(
+            '-c', '--post-count',
+            help="The number of posts to create.",
+            type=int,
+            default=100)
+    parser.add_argument(
+            '--tag-count',
+            help="The number of tags to use.",
+            type=int,
+            default=30)
+
+    result = parser.parse_args()
+    generate(result.engine, result.out_dir,
+             post_count=result.post_count,
+             tag_count=result.tag_count)
+
+
+def generate(engine, out_dir, post_count=100, tag_count=10):
+    print("Generating %d posts in %s..." % (post_count, out_dir))
+
+    if not os.path.exists(out_dir):
+        os.makedirs(out_dir)
+
+    gen = generators[engine](out_dir)
+    gen.all_tags = [generateWord(3, 12) for _ in range(tag_count)]
+    gen.initialize()
+
+    for i in range(post_count):
+        gen.generatePost()
+
+
+if __name__ == '__main__':
+    main()
+else:
+    from invoke import task
+
+    @task
+    def genbenchsite(engine, out_dir, post_count=100, tag_count=10):
+        generate(engine, out_dir,
+                 post_count=post_count,
+                 tag_count=tag_count)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/changelog.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,183 @@
+import os
+import os.path
+import re
+import time
+import argparse
+import subprocess
+
+
+hg_log_template = ("{if(tags, '>>{tags};{date|shortdate}\n')}"
+                   "{desc|firstline}\n\n")
+
+re_add_tag_changeset = re.compile('^Added tag [^\s]+ for changeset [\w\d]+$')
+re_merge_pr_changeset = re.compile('^Merge pull request')
+re_tag = re.compile('^\d+\.\d+\.\d+([ab]\d+)?(rc\d+)?$')
+re_change = re.compile('^(\w+):')
+re_clean_code_span = re.compile('([^\s])``([^\s]+)')
+
+category_commands = [
+        'chef', 'bake', 'find', 'help', 'import', 'init', 'paths', 'plugin',
+        'plugins', 'prepare', 'purge', 'root', 'routes', 'serve',
+        'showconfig', 'showrecord', 'sources', 'theme', 'themes', 'admin',
+        'publish']
+category_core = [
+        'internal', 'bug', 'templating', 'formatting', 'performance',
+        'data', 'config', 'rendering', 'render', 'debug', 'reporting',
+        'linker', 'pagination', 'routing', 'caching', 'cli']
+category_project = ['build', 'cm', 'docs', 'tests', 'setup']
+categories = [
+        ('commands', category_commands),
+        ('core', category_core),
+        ('project', category_project),
+        ('miscellaneous', None)]
+category_names = list(map(lambda i: i[0], categories))
+
+
+def generate(out_file, last=None):
+    print("Generating %s" % out_file)
+
+    if not os.path.exists('.hg'):
+        raise Exception("You must run this script from the root of a "
+                        "Mercurial clone of the PieCrust repository.")
+    hglog = subprocess.check_output([
+        'hg', 'log',
+        '--rev', 'reverse(::master)',
+        '--template', hg_log_template])
+    hglog = hglog.decode('utf8')
+
+    templates = _get_templates()
+
+    with open(out_file, 'w', encoding='utf8', newline='') as fp:
+        fp.write(templates['header'])
+
+        skip = False
+        in_desc = False
+        current_version = 0
+        current_version_info = None
+        current_changes = None
+
+        if last:
+            current_version = 1
+            cur_date = time.strftime('%Y-%m-%d')
+            current_version_info = last, cur_date
+            current_changes = {}
+
+        for line in hglog.splitlines():
+            if line == '':
+                skip = False
+                in_desc = False
+                continue
+
+            if not in_desc and line.startswith('>>'):
+                tags, tag_date = line[2:].split(';')
+                if re_tag.match(tags):
+                    if current_version > 0:
+                        _write_version_changes(
+                                templates,
+                                current_version, current_version_info,
+                                current_changes, fp)
+
+                    current_version += 1
+                    current_version_info = tags, tag_date
+                    current_changes = {}
+                    in_desc = True
+                else:
+                    skip = True
+                continue
+
+            if skip or current_version == 0:
+                continue
+
+            if re_add_tag_changeset.match(line):
+                continue
+            if re_merge_pr_changeset.match(line):
+                continue
+
+            m = re_change.match(line)
+            if m:
+                ch_type = m.group(1)
+                for cat_name, ch_types in categories:
+                    if ch_types is None or ch_type in ch_types:
+                        msgs = current_changes.setdefault(cat_name, [])
+                        msgs.append(line)
+                        break
+                else:
+                    assert False, ("Change '%s' should have gone in the "
+                                   "misc. bucket." % line)
+            else:
+                msgs = current_changes.setdefault('miscellaneous', [])
+                msgs.append(line)
+
+        if current_version > 0:
+            _write_version_changes(
+                    templates,
+                    current_version, current_version_info,
+                    current_changes, fp)
+
+
+def _write_version_changes(templates, version, version_info, changes, fp):
+    tokens = {
+            'num': str(version),
+            'version': version_info[0],
+            'date': version_info[1]}
+    tpl = _multi_replace(templates['version_title'], tokens)
+    fp.write(tpl)
+
+    for i, cat_name in enumerate(category_names):
+        msgs = changes.get(cat_name)
+        if not msgs:
+            continue
+
+        tokens = {
+                'sub_num': str(i),
+                'category': cat_name.title()}
+        tpl = _multi_replace(templates['category_title'], tokens)
+        fp.write(tpl)
+
+        for msg in msgs:
+            msg = msg.replace('`', '``').rstrip('\n')
+            msg = re_clean_code_span.sub(r'\1`` \2', msg)
+            fp.write('* ' + msg + '\n')
+
+
+def _multi_replace(s, tokens):
+    for token in tokens:
+        s = s.replace('%%%s%%' % token, tokens[token])
+    return s
+
+
+def _get_templates():
+    tpl_dir = os.path.join(os.path.dirname(__file__), 'changelog')
+    tpls = {}
+    for name in os.listdir(tpl_dir):
+        tpl = _get_template(os.path.join(tpl_dir, name))
+        name_no_ext, _ = os.path.splitext(name)
+        tpls[name_no_ext] = tpl
+    return tpls
+
+
+def _get_template(filename):
+    with open(filename, 'r', encoding='utf8', newline='') as fp:
+        return fp.read()
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Generate CHANGELOG file.')
+    parser.add_argument(
+            'out_file',
+            nargs='?',
+            default='CHANGELOG.rst',
+            help='The output file.')
+    parser.add_argument(
+            '--last',
+            help="The version for the last few untagged changes.")
+    args = parser.parse_args()
+
+    generate(args.out_file, last=args.last)
+else:
+    from invoke import task
+
+    @task
+    def genchangelog(out_file='CHANGELOG.rst', last=None):
+        generate(out_file, last)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/changelog/category_title.rst	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,4 @@
+
+1.%sub_num% %category%
+----------------------
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/changelog/header.rst	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,10 @@
+
+#########
+CHANGELOG
+#########
+
+This is the changelog for PieCrust_.
+
+.. _PieCrust: http://bolt80.com/piecrust/
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/changelog/version_title.rst	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,5 @@
+
+==================================
+%num%. PieCrust %version% (%date%)
+==================================
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/documentation.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,50 @@
+import os
+import os.path
+from invoke import task, run
+
+
+@task
+def gendocs(tmp_dir=None, out_dir=None, root_url=None):
+    if not tmp_dir:
+        tmp_dir = '_docs-counter'
+
+    if not os.path.isdir('venv'):
+        raise Exception(
+                "You need a virtual environment in the PieCrust repo.")
+    pyexe = os.path.join('venv', 'bin', 'python')
+
+    print("Update node modules")
+    run("npm install")
+
+    print("Update Bower packages")
+    run("bower update")
+
+    print("Generate PieCrust version")
+    run(pyexe + ' setup.py version')
+    from piecrust.__version__ import APP_VERSION
+    version = APP_VERSION
+
+    print("Baking documentation for version: %s" % version)
+    if root_url:
+        print("Using root URL: %s" % root_url)
+    args = [
+            pyexe, 'chef.py',
+            '--root', 'docs',
+            '--config', 'dist']
+    if root_url:
+        args += ['--config-set', 'site/root', root_url]
+    args += [
+            'bake',
+            '-o', tmp_dir
+            ]
+    run(' '.join(args))
+
+    if out_dir:
+        print("Synchronizing %s" % out_dir)
+        if not os.path.isdir(out_dir):
+            os.makedirs(out_dir)
+
+        tmp_dir = tmp_dir.rstrip('/') + '/'
+        out_dir = out_dir.rstrip('/') + '/'
+        run('rsync -av --delete %s %s' % (tmp_dir, out_dir))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,11 @@
+import os
+from invoke import task, run
+
+
+@task
+def genmessages():
+    root_dir = 'garcon/messages'
+    out_dir = 'piecrust/resources/messages'
+    run('python chef.py --root %s bake -o %s' % (root_dir, out_dir))
+    os.unlink('piecrust/resources/messages/index.html')
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/config.yml	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,2 @@
+site:
+    title: PieCrust System Messages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/pages/_index.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,12 @@
+---
+title: PieCrust System Messages
+---
+
+Here are the **PieCrust** system message pages:
+
+* [Requirements Not Met]({{ pcurl('requirements') }})
+* [Error]({{ pcurl('error') }})
+* [Not Found]({{ pcurl('error404') }})
+* [Critical Error]({{ pcurl('critical') }})
+
+This very page you're reading, however, is only here for convenience.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/pages/critical.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,6 @@
+---
+title: The Whole Kitchen Burned Down!
+layout: error
+---
+Something critically bad happened, and **PieCrust** needs to shut down. It's probably our fault.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/pages/error.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,5 @@
+---
+title: The Cake Just Burned!
+layout: error
+---
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/pages/error404.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,6 @@
+---
+title: Can't find the sugar!
+layout: error
+---
+It looks like the page you were trying to access does not exist around here. Try going somewhere else.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/templates/default.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,70 @@
+<!doctype html>
+<html>
+<head>
+    <title>{{ page.title }}</title>
+    <meta name="generator" content="PieCrust" />
+	<link rel="stylesheet" type="text/css" href="http://fonts.googleapis.com/css?family=Lobster">
+	<style>
+		body {
+			margin: 0;
+			padding: 1em;
+			background: #eee;
+			color: #000;
+			font-family: Georgia, serif;
+		}
+		h1 {
+			font-size: 4.5em;
+			font-family: Lobster, 'Trebuchet MS', Verdana, sans-serif;
+			text-align: center;
+			font-weight: bold;
+            margin-top: 0;
+			color: #333;
+			text-shadow: 0px 2px 5px rgba(0,0,0,0.3);
+		}
+		h2 {
+			font-size: 2.5em;
+            font-family: 'Lobster', 'Trebuchet MS', Verdana, sans-serif;
+		}
+		code {
+			background: #ddd;
+			padding: 0 0.2em;
+		}
+        #preamble {
+            font-size: 1.2em;
+            font-style: italic;
+            text-align: center;
+            margin-bottom: 0;
+        }
+		#container {
+			margin: 0 20%;
+		}
+        #content {
+            margin: 2em 1em;
+        }
+        .error-details {
+            color: #d11;
+        }
+		.note {
+			margin: 3em;
+			color: #888;
+			font-style: italic;
+		}
+	</style>
+</head>
+<body>
+    <div id="container">
+        <div id="header">
+            <p id="preamble">A Message From The Kitchen:</p>
+			<h1>{{ page.title }}</h1>
+		</div>
+        <hr />
+        <div id="content">
+            {% block content %}
+			{{ content|safe }}
+            {% endblock %}
+		</div>
+        <hr />
+        {% block footer %}{% endblock %}
+	</div>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/messages/templates/error.html	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,27 @@
+{% extends "default.html" %}
+
+{% block content %}
+{{content|safe}}
+
+{# The following is `raw` because we want it to be in the
+   produced page, so it can then be templated on the fly 
+   with the error messages #}
+{% raw %}
+{% if details %}
+<div class="error-details">
+    <p>Error details:</p>
+    <ul>
+    {% for desc in details %}
+        <li>{{ desc }}</li>
+    {% endfor %}
+    </ul>
+</div>
+{% endif %}
+{% endraw %}
+{% endblock %}
+
+{% block footer %}
+{% pcformat 'textile' %}
+p(note). You're seeing this because something wrong happend. To see detailed errors with callstacks, run chef with the @--debug@ parameter, append @?!debug@ to the URL, or initialize the @PieCrust@ object with @{'debug': true}@. On the other hand, to see you custom error pages, set the @site/display_errors@ setting  to @false@.
+{% endpcformat %}
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/garcon/pypi.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,29 @@
+from invoke import task, run
+
+
+@task
+def makerelease(version, local_only=False):
+    if not version:
+        raise Exception("You must specify a version!")
+
+    # FoodTruck assets.
+    print("Update node modules")
+    run("npm install")
+    print("Install Bower components")
+    run("bower install")
+    print("Generating FoodTruck assets")
+    run("gulp")
+
+    # CHANGELOG.rst
+    run("invoke changelog --last %s" % version)
+
+    if not local_only:
+        # Submit the CHANGELOG.
+        run('hg commit CHANGELOG.rst -m "cm: Regenerate the CHANGELOG."')
+
+        # Tag in Mercurial, which will then be used for PyPi version.
+        run("hg tag %s" % version)
+
+        # PyPi upload.
+        run("python setup.py sdist upload")
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gulpfile.js	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,81 @@
+'use strict';
+
+var gulp = require('gulp'),
+    util = require('gulp-util'),
+    sass = require('gulp-sass'),
+    sourcemaps = require('gulp-sourcemaps'),
+    rename = require('gulp-rename'),
+    cssnano = require('gulp-cssnano'),
+    concat = require('gulp-concat'),
+    uglify = require('gulp-uglify');
+
+// Stylesheets
+gulp.task('sass', function() {
+    return gulp.src('foodtruck/assets/sass/**/*.scss')
+        //.pipe(sourcemaps.init())
+        .pipe(sass({
+            errLogToConsole: true,
+            outputStyle: 'compressed',
+            includePaths: [
+                'bower_components/bootstrap-sass/assets/stylesheets',
+                'bower_components/Ionicons/scss']}))
+        .pipe(cssnano())
+        //.pipe(sourcemaps.write())
+        .pipe(rename({suffix: '.min'}))
+        .pipe(gulp.dest('foodtruck/static/css'));
+});
+gulp.task('sass:watch', function() {
+    return gulp.watch('foodtruck/assets/sass/**/*.scss', ['sass']);
+});
+
+// Javascript
+gulp.task('js', function() {
+    return gulp.src([
+            'bower_components/jquery/dist/jquery.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/alert.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/button.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/collapse.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/dropdown.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/modal.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/tooltip.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/transition.js',
+            'foodtruck/assets/js/**/*.js'
+            ])
+        .pipe(sourcemaps.init())
+        .pipe(concat('foodtruck.js'))
+        //.pipe(uglify())
+        .pipe(sourcemaps.write())
+        .pipe(rename({suffix: '.min'}))
+        .pipe(gulp.dest('foodtruck/static/js'));
+});
+gulp.task('js:watch', function() {
+    return gulp.watch('foodtruck/assets/js/**/*.js', ['js']);
+});
+
+// Fonts/images
+gulp.task('fonts', function() {
+    return gulp.src([
+            'bower_components/bootstrap-sass/assets/fonts/bootstrap/*',
+            'bower_components/Ionicons/fonts/*'
+        ])
+        .pipe(gulp.dest('foodtruck/static/fonts'));
+});
+
+gulp.task('images', function() {
+    return gulp.src([
+            'bower_components/bootstrap-sass/assets/images/*',
+            'foodtruck/assets/img/*'
+        ])
+        .pipe(gulp.dest('foodtruck/static/img'));
+});
+
+// Launch tasks
+gulp.task('default', function() {
+    gulp.start(['sass', 'js', 'fonts', 'images']);
+});
+
+gulp.task('watch', function() {
+    gulp.start(['sass:watch', 'js:watch']);
+});
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/package.json	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,20 @@
+{
+  "name": "PieCrust",
+  "version": "1.0.0",
+  "license": "Apache-2.0",
+  "repository": "http://bitbucket.org/ludovicchabant/piecrust2",
+  "devDependencies": {
+    "clean-css": "^3.4.9",
+    "gulp": "~3.9.0",
+    "gulp-concat": "~2.6.0",
+    "gulp-cssnano": "^2.1.1",
+    "gulp-less": "^3.0.5",
+    "gulp-rename": "~1.2.2",
+    "gulp-sass": "~2.1.0",
+    "gulp-sourcemaps": "~1.6.0",
+    "gulp-uglify": "^1.5.2",
+    "gulp-util": "~3.0.7",
+    "less": "^2.6.0",
+    "uglify-js": "^2.6.1"
+  }
+}
--- a/piecrust/__init__.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/__init__.py	Mon Feb 22 22:50:30 2016 -0800
@@ -17,6 +17,8 @@
 
 PIECRUST_URL = 'http://bolt80.com/piecrust/'
 
+CACHE_VERSION = 22
+
 try:
     from piecrust.__version__ import APP_VERSION
 except ImportError:
--- a/piecrust/app.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/app.py	Mon Feb 22 22:50:30 2016 -0800
@@ -1,404 +1,26 @@
-import re
-import json
 import time
 import os.path
-import urllib.parse
-import codecs
 import hashlib
 import logging
-import collections
-import yaml
 from werkzeug.utils import cached_property
 from piecrust import (
-        APP_VERSION, RESOURCES_DIR,
+        RESOURCES_DIR,
         CACHE_DIR, TEMPLATES_DIR, ASSETS_DIR,
         THEME_DIR,
-        CONFIG_PATH, THEME_CONFIG_PATH,
-        DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS,
-        DEFAULT_DATE_FORMAT, DEFAULT_THEME_SOURCE)
-from piecrust.cache import ExtensibleCache, NullCache, NullExtensibleCache
+        CONFIG_PATH, THEME_CONFIG_PATH)
+from piecrust.appconfig import PieCrustConfiguration
+from piecrust.cache import ExtensibleCache, NullExtensibleCache
 from piecrust.plugins.base import PluginLoader
 from piecrust.environment import StandardEnvironment
-from piecrust.configuration import (
-        Configuration, ConfigurationError, ConfigurationLoader,
-        merge_dicts)
+from piecrust.configuration import ConfigurationError
 from piecrust.routing import Route
-from piecrust.sources.base import REALM_USER, REALM_THEME
+from piecrust.sources.base import REALM_THEME
 from piecrust.taxonomies import Taxonomy
 
 
 logger = logging.getLogger(__name__)
 
 
-CACHE_VERSION = 22
-
-
-class VariantNotFoundError(Exception):
-    def __init__(self, variant_path, message=None):
-        super(VariantNotFoundError, self).__init__(
-                message or ("No such configuration variant: %s" % variant_path))
-
-
-class PieCrustConfiguration(Configuration):
-    def __init__(self, paths=None, cache=None, values=None, validate=True):
-        super(PieCrustConfiguration, self).__init__(values, validate)
-        self.paths = paths
-        self.cache = cache or NullCache()
-        self.fixups = []
-
-    def applyVariant(self, variant_path, raise_if_not_found=True):
-        variant = self.get(variant_path)
-        if variant is None:
-            if raise_if_not_found:
-                raise VariantNotFoundError(variant_path)
-            return
-        if not isinstance(variant, dict):
-            raise VariantNotFoundError(variant_path,
-                    "Configuration variant '%s' is not an array. "
-                    "Check your configuration file." % variant_path)
-        self.merge(variant)
-
-    def _load(self):
-        if self.paths is None:
-            self._values = self._validateAll({})
-            return
-
-        path_times = [os.path.getmtime(p) for p in self.paths]
-        cache_key_hash = hashlib.md5(("version=%s&cache=%d" % (
-                APP_VERSION, CACHE_VERSION)).encode('utf8'))
-        for p in self.paths:
-            cache_key_hash.update(("&path=%s" % p).encode('utf8'))
-        cache_key = cache_key_hash.hexdigest()
-
-        if self.cache.isValid('config.json', path_times):
-            logger.debug("Loading configuration from cache...")
-            config_text = self.cache.read('config.json')
-            self._values = json.loads(config_text,
-                    object_pairs_hook=collections.OrderedDict)
-
-            actual_cache_key = self._values.get('__cache_key')
-            if actual_cache_key == cache_key:
-                self._values['__cache_valid'] = True
-                return
-            logger.debug("Outdated cache key '%s' (expected '%s')." % (
-                    actual_cache_key, cache_key))
-
-        values = {}
-        logger.debug("Loading configuration from: %s" % self.paths)
-        for i, p in enumerate(self.paths):
-            with codecs.open(p, 'r', 'utf-8') as fp:
-                loaded_values = yaml.load(fp.read(),
-                        Loader=ConfigurationLoader)
-            if loaded_values is None:
-                loaded_values = {}
-            for fixup in self.fixups:
-                fixup(i, loaded_values)
-            merge_dicts(values, loaded_values)
-
-        for fixup in self.fixups:
-            fixup(len(self.paths), values)
-
-        self._values = self._validateAll(values)
-
-        logger.debug("Caching configuration...")
-        self._values['__cache_key'] = cache_key
-        config_text = json.dumps(self._values)
-        self.cache.write('config.json', config_text)
-        self._values['__cache_valid'] = False
-
-    def _validateAll(self, values):
-        # Put all the defaults in the `site` section.
-        default_sitec = collections.OrderedDict({
-                'title': "Untitled PieCrust website",
-                'root': '/',
-                'default_format': DEFAULT_FORMAT,
-                'default_template_engine': DEFAULT_TEMPLATE_ENGINE,
-                'enable_gzip': True,
-                'pretty_urls': False,
-                'trailing_slash': False,
-                'date_format': DEFAULT_DATE_FORMAT,
-                'auto_formats': collections.OrderedDict([
-                    ('html', ''),
-                    ('md', 'markdown'),
-                    ('textile', 'textile')]),
-                'default_auto_format': 'md',
-                'pagination_suffix': '/%num%',
-                'slugify_mode': 'encode',
-                'plugins': None,
-                'themes_sources': [DEFAULT_THEME_SOURCE],
-                'cache_time': 28800,
-                'enable_debug_info': True,
-                'show_debug_info': False,
-                'use_default_content': True
-                })
-        sitec = values.get('site')
-        if sitec is None:
-            sitec = collections.OrderedDict()
-        for key, val in default_sitec.items():
-            sitec.setdefault(key, val)
-        values['site'] = sitec
-
-        # Add a section for our cached information.
-        cachec = collections.OrderedDict()
-        values['__cache'] = cachec
-
-        # Make sure the site root starts and ends with a slash.
-        if not sitec['root'].startswith('/'):
-            raise ConfigurationError("The `site/root` setting must start "
-                                     "with a slash.")
-        sitec['root'] = urllib.parse.quote(sitec['root'].rstrip('/') + '/')
-
-        # Cache auto-format regexes.
-        if not isinstance(sitec['auto_formats'], dict):
-            raise ConfigurationError("The 'site/auto_formats' setting must be "
-                                     "a dictionary.")
-        # Check that `.html` is in there.
-        sitec['auto_formats'].setdefault('html', sitec['default_format'])
-        cachec['auto_formats_re'] = r"\.(%s)$" % (
-                '|'.join(
-                        [re.escape(i) for i in
-                            list(sitec['auto_formats'].keys())]))
-        if sitec['default_auto_format'] not in sitec['auto_formats']:
-            raise ConfigurationError("Default auto-format '%s' is not "
-                                     "declared." %
-                                     sitec['default_auto_format'])
-
-        # Cache pagination suffix regex and format.
-        pgn_suffix = sitec['pagination_suffix']
-        if len(pgn_suffix) == 0 or pgn_suffix[0] != '/':
-            raise ConfigurationError("The 'site/pagination_suffix' setting "
-                                     "must start with a slash.")
-        if '%num%' not in pgn_suffix:
-            raise ConfigurationError("The 'site/pagination_suffix' setting "
-                                     "must contain the '%num%' placeholder.")
-
-        pgn_suffix_fmt = pgn_suffix.replace('%num%', '%(num)d')
-        cachec['pagination_suffix_format'] = pgn_suffix_fmt
-
-        pgn_suffix_re = re.escape(pgn_suffix)
-        pgn_suffix_re = (pgn_suffix_re.replace("\\%num\\%", "(?P<num>\\d+)") +
-                         '$')
-        cachec['pagination_suffix_re'] = pgn_suffix_re
-
-        # Make sure theme sources is a list.
-        if not isinstance(sitec['themes_sources'], list):
-            sitec['themes_sources'] = [sitec['themes_sources']]
-
-        # Figure out if we need to validate sources/routes, or auto-generate
-        # them from simple blog settings.
-        orig_sources = sitec.get('sources')
-        orig_routes = sitec.get('routes')
-        orig_taxonomies = sitec.get('taxonomies')
-        use_default_content = sitec.get('use_default_content')
-        if (orig_sources is None or orig_routes is None or
-                orig_taxonomies is None or use_default_content):
-
-            # Setup defaults for various settings.
-            posts_fs = sitec.setdefault('posts_fs', DEFAULT_POSTS_FS)
-            blogsc = sitec.setdefault('blogs', ['posts'])
-
-            g_page_layout = sitec.get('default_page_layout', 'default')
-            g_post_layout = sitec.get('default_post_layout', 'post')
-            g_post_url = sitec.get('post_url', '%year%/%month%/%day%/%slug%')
-            g_tag_url = sitec.get('tag_url', 'tag/%tag%')
-            g_category_url = sitec.get('category_url', '%category%')
-            g_posts_per_page = sitec.get('posts_per_page', 5)
-            g_posts_filters = sitec.get('posts_filters')
-            g_date_format = sitec.get('date_format', DEFAULT_DATE_FORMAT)
-
-            # The normal pages and tags/categories.
-            sourcesc = collections.OrderedDict()
-            sourcesc['pages'] = {
-                    'type': 'default',
-                    'ignore_missing_dir': True,
-                    'data_endpoint': 'site.pages',
-                    'default_layout': g_page_layout,
-                    'item_name': 'page'}
-            sitec['sources'] = sourcesc
-
-            routesc = []
-            routesc.append({
-                    'url': '/%path:slug%',
-                    'source': 'pages',
-                    'func': 'pcurl(slug)'})
-            sitec['routes'] = routesc
-
-            taxonomiesc = collections.OrderedDict()
-            taxonomiesc['tags'] = {
-                    'multiple': True,
-                    'term': 'tag'}
-            taxonomiesc['categories'] = {
-                    'term': 'category'}
-            sitec['taxonomies'] = taxonomiesc
-
-            # Setup sources/routes/taxonomies for each blog.
-            for blog_name in blogsc:
-                blogc = values.get(blog_name, {})
-                url_prefix = blog_name + '/'
-                fs_endpoint = 'posts/%s' % blog_name
-                data_endpoint = blog_name
-                item_name = '%s-post' % blog_name
-                items_per_page = blogc.get('posts_per_page', g_posts_per_page)
-                items_filters = blogc.get('posts_filters', g_posts_filters)
-                date_format = blogc.get('date_format', g_date_format)
-                if len(blogsc) == 1:
-                    url_prefix = ''
-                    fs_endpoint = 'posts'
-                    data_endpoint = 'blog'
-                    item_name = 'post'
-                sourcesc[blog_name] = {
-                        'type': 'posts/%s' % posts_fs,
-                        'fs_endpoint': fs_endpoint,
-                        'data_endpoint': data_endpoint,
-                        'ignore_missing_dir': True,
-                        'data_type': 'blog',
-                        'item_name': item_name,
-                        'items_per_page': items_per_page,
-                        'items_filters': items_filters,
-                        'date_format': date_format,
-                        'default_layout': g_post_layout}
-                tax_page_prefix = ''
-                if len(blogsc) > 1:
-                    tax_page_prefix = blog_name + '/'
-                sourcesc[blog_name]['taxonomy_pages'] = {
-                        'tags': ('pages:%s_tag.%%ext%%;'
-                                 'theme_pages:_tag.%%ext%%' %
-                                 tax_page_prefix),
-                        'categories': ('pages:%s_category.%%ext%%;'
-                                       'theme_pages:_category.%%ext%%' %
-                                       tax_page_prefix)}
-
-                post_url = blogc.get('post_url', url_prefix + g_post_url)
-                post_url = '/' + post_url.lstrip('/')
-                tag_url = blogc.get('tag_url', url_prefix + g_tag_url)
-                tag_url = '/' + tag_url.lstrip('/')
-                category_url = blogc.get('category_url', url_prefix + g_category_url)
-                category_url = '/' + category_url.lstrip('/')
-                routesc.append({'url': post_url, 'source': blog_name,
-                        'func': 'pcposturl(year,month,day,slug)'})
-                routesc.append({'url': tag_url, 'source': blog_name,
-                        'taxonomy': 'tags',
-                        'func': 'pctagurl(tag)'})
-                routesc.append({'url': category_url, 'source': blog_name,
-                        'taxonomy': 'categories',
-                        'func': 'pccaturl(category)'})
-
-            # If the user defined some additional sources/routes/taxonomies,
-            # add them to the default ones. For routes, the order matters,
-            # though, so we make sure to add the user routes at the front
-            # of the list so they're evaluated first.
-            if orig_sources:
-                sourcesc.update(orig_sources)
-            sitec['sources'] = sourcesc
-            if orig_routes:
-                routesc = orig_routes + routesc
-            sitec['routes'] = routesc
-            if orig_taxonomies:
-                taxonomiesc.update(orig_taxonomies)
-            sitec['taxonomies'] = taxonomiesc
-
-        # Validate sources/routes.
-        sourcesc = sitec.get('sources')
-        routesc = sitec.get('routes')
-        if not sourcesc:
-            raise ConfigurationError("There are no sources defined.")
-        if not routesc:
-            raise ConfigurationError("There are no routes defined.")
-        if not isinstance(sourcesc, dict):
-            raise ConfigurationError("The 'site/sources' setting must be a "
-                                     "dictionary.")
-        if not isinstance(routesc, list):
-            raise ConfigurationError("The 'site/routes' setting must be a "
-                                     "list.")
-
-        # Add the theme page source if no sources were defined in the theme
-        # configuration itself.
-        has_any_theme_source = False
-        for sn, sc in sourcesc.items():
-            if sc.get('realm') == REALM_THEME:
-                has_any_theme_source = True
-                break
-        if not has_any_theme_source:
-            sitec['sources']['theme_pages'] = {
-                    'theme_source': True,
-                    'fs_endpoint': 'pages',
-                    'data_endpoint': 'site/pages',
-                    'item_name': 'page',
-                    'realm': REALM_THEME}
-            sitec['routes'].append({
-                    'url': '/%path:slug%',
-                    'source': 'theme_pages',
-                    'func': 'pcurl(slug)'})
-
-        # Sources have the `default` scanner by default, duh. Also, a bunch
-        # of other default values for other configuration stuff.
-        for sn, sc in sourcesc.items():
-            if not isinstance(sc, dict):
-                raise ConfigurationError("All sources in 'site/sources' must "
-                                         "be dictionaries.")
-            sc.setdefault('type', 'default')
-            sc.setdefault('fs_endpoint', sn)
-            sc.setdefault('ignore_missing_dir', False)
-            sc.setdefault('data_endpoint', sn)
-            sc.setdefault('data_type', 'iterator')
-            sc.setdefault('item_name', sn)
-            sc.setdefault('items_per_page', 5)
-            sc.setdefault('date_format', DEFAULT_DATE_FORMAT)
-            sc.setdefault('realm', REALM_USER)
-
-        # Check routes are referencing correct routes, have default
-        # values, etc.
-        for rc in routesc:
-            if not isinstance(rc, dict):
-                raise ConfigurationError("All routes in 'site/routes' must be "
-                                         "dictionaries.")
-            rc_url = rc.get('url')
-            if not rc_url:
-                raise ConfigurationError("All routes in 'site/routes' must "
-                                         "have an 'url'.")
-            if rc_url[0] != '/':
-                raise ConfigurationError("Route URLs must start with '/'.")
-            if rc.get('source') is None:
-                raise ConfigurationError("Routes must specify a source.")
-            if rc['source'] not in list(sourcesc.keys()):
-                raise ConfigurationError("Route is referencing unknown "
-                                         "source: %s" % rc['source'])
-            rc.setdefault('taxonomy', None)
-            rc.setdefault('page_suffix', '/%num%')
-
-        # Validate taxonomies.
-        sitec.setdefault('taxonomies', {})
-        taxonomiesc = sitec.get('taxonomies')
-        for tn, tc in taxonomiesc.items():
-            tc.setdefault('multiple', False)
-            tc.setdefault('term', tn)
-            tc.setdefault('page', '_%s.%%ext%%' % tc['term'])
-
-        # Validate endpoints, and make sure the theme has a default source.
-        reserved_endpoints = set(['piecrust', 'site', 'page', 'route',
-                                  'assets', 'pagination', 'siblings',
-                                  'family'])
-        for name, src in sitec['sources'].items():
-            endpoint = src['data_endpoint']
-            if endpoint in reserved_endpoints:
-                raise ConfigurationError(
-                        "Source '%s' is using a reserved endpoint name: %s" %
-                        (name, endpoint))
-
-        # Make sure the `plugins` setting is a list.
-        user_plugins = sitec.get('plugins')
-        if user_plugins:
-            if isinstance(user_plugins, str):
-                sitec['plugins'] = user_plugins.split(',')
-            elif not isinstance(user_plugins, list):
-                raise ConfigurationError(
-                        "The 'site/plugins' setting must be an array, or a "
-                        "comma-separated list.")
-
-        # Done validating!
-        return values
-
-
 class PieCrust(object):
     def __init__(self, root_dir, cache=True, debug=False, theme_site=False,
                  env=None):
@@ -420,6 +42,11 @@
         self.env.registerTimer('SiteConfigLoad')
         self.env.registerTimer('PageLoad')
         self.env.registerTimer("PageDataBuild")
+        self.env.registerTimer("BuildRenderData")
+        self.env.registerTimer("PageRender")
+        self.env.registerTimer("PageRenderSegments")
+        self.env.registerTimer("PageRenderLayout")
+        self.env.registerTimer("PageSerialize")
 
     @cached_property
     def config(self):
@@ -454,8 +81,8 @@
 
     @cached_property
     def assets_dirs(self):
-        assets_dirs = self._get_configurable_dirs(ASSETS_DIR,
-                'site/assets_dirs')
+        assets_dirs = self._get_configurable_dirs(
+                ASSETS_DIR, 'site/assets_dirs')
 
         # Also add the theme directory, if any.
         if self.theme_dir:
@@ -467,8 +94,8 @@
 
     @cached_property
     def templates_dirs(self):
-        templates_dirs = self._get_configurable_dirs(TEMPLATES_DIR,
-                'site/templates_dirs')
+        templates_dirs = self._get_configurable_dirs(
+                TEMPLATES_DIR, 'site/templates_dirs')
 
         # Also, add the theme directory, if any.
         if self.theme_dir:
@@ -505,7 +132,8 @@
         for n, s in self.config.get('site/sources').items():
             cls = defs.get(s['type'])
             if cls is None:
-                raise ConfigurationError("No such page source type: %s" % s['type'])
+                raise ConfigurationError("No such page source type: %s" %
+                                         s['type'])
             src = cls(self, n, s)
             sources.append(src)
         return sources
@@ -548,7 +176,8 @@
 
     def getTaxonomyRoute(self, tax_name, source_name):
         for route in self.routes:
-            if route.taxonomy_name == tax_name and route.source_name == source_name:
+            if (route.taxonomy_name == tax_name and
+                    route.source_name == source_name):
                 return route
         return None
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/appconfig.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,514 @@
+import re
+import os.path
+import copy
+import json
+import urllib
+import logging
+import hashlib
+import collections
+import yaml
+from piecrust import (
+        APP_VERSION, CACHE_VERSION,
+        DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS,
+        DEFAULT_DATE_FORMAT, DEFAULT_THEME_SOURCE)
+from piecrust.cache import NullCache
+from piecrust.configuration import (
+        Configuration, ConfigurationError, ConfigurationLoader,
+        merge_dicts, visit_dict)
+from piecrust.sources.base import REALM_USER, REALM_THEME
+
+
+logger = logging.getLogger(__name__)
+
+
+class VariantNotFoundError(Exception):
+    def __init__(self, variant_path, message=None):
+        super(VariantNotFoundError, self).__init__(
+                message or ("No such configuration variant: %s" %
+                            variant_path))
+
+
+class PieCrustConfiguration(Configuration):
+    def __init__(self, paths=None, cache=None, values=None, validate=True):
+        super(PieCrustConfiguration, self).__init__(values, validate)
+        self.paths = paths
+        self.cache = cache or NullCache()
+        self.fixups = []
+
+    def applyVariant(self, variant_path, raise_if_not_found=True):
+        variant = self.get(variant_path)
+        if variant is None:
+            if raise_if_not_found:
+                raise VariantNotFoundError(variant_path)
+            return
+        if not isinstance(variant, dict):
+            raise VariantNotFoundError(
+                    variant_path,
+                    "Configuration variant '%s' is not an array. "
+                    "Check your configuration file." % variant_path)
+        self.merge(variant)
+
+    def _load(self):
+        if self.paths is None:
+            self._values = self._validateAll({})
+            return
+
+        path_times = [os.path.getmtime(p) for p in self.paths]
+
+        cache_key_hash = hashlib.md5(
+                ("version=%s&cache=%d" % (
+                    APP_VERSION, CACHE_VERSION)).encode('utf8'))
+        for p in self.paths:
+            cache_key_hash.update(("&path=%s" % p).encode('utf8'))
+        cache_key = cache_key_hash.hexdigest()
+
+        if self.cache.isValid('config.json', path_times):
+            logger.debug("Loading configuration from cache...")
+            config_text = self.cache.read('config.json')
+            self._values = json.loads(
+                    config_text,
+                    object_pairs_hook=collections.OrderedDict)
+
+            actual_cache_key = self._values.get('__cache_key')
+            if actual_cache_key == cache_key:
+                self._values['__cache_valid'] = True
+                return
+            logger.debug("Outdated cache key '%s' (expected '%s')." % (
+                    actual_cache_key, cache_key))
+
+        logger.debug("Loading configuration from: %s" % self.paths)
+        values = {}
+        try:
+            for i, p in enumerate(self.paths):
+                with open(p, 'r', encoding='utf-8') as fp:
+                    loaded_values = yaml.load(
+                            fp.read(),
+                            Loader=ConfigurationLoader)
+                if loaded_values is None:
+                    loaded_values = {}
+                for fixup in self.fixups:
+                    fixup(i, loaded_values)
+                merge_dicts(values, loaded_values)
+
+            for fixup in self.fixups:
+                fixup(len(self.paths), values)
+
+            self._values = self._validateAll(values)
+        except Exception as ex:
+            raise Exception("Error loading configuration from: %s" %
+                            ', '.join(self.paths)) from ex
+
+        logger.debug("Caching configuration...")
+        self._values['__cache_key'] = cache_key
+        config_text = json.dumps(self._values)
+        self.cache.write('config.json', config_text)
+
+        self._values['__cache_valid'] = False
+
+    def _validateAll(self, values):
+        if values is None:
+            values = {}
+
+        # Add the loaded values to the default configuration.
+        values = merge_dicts(copy.deepcopy(default_configuration), values)
+
+        # Figure out if we need to generate the configuration for the
+        # default content model.
+        sitec = values.setdefault('site', {})
+        if (
+                ('sources' not in sitec and
+                 'routes' not in sitec and
+                 'taxonomies' not in sitec) or
+                sitec.get('use_default_content')):
+            logger.debug("Generating default content model...")
+            values = self._generateDefaultContentModel(values)
+
+        # Add a section for our cached information.
+        cachec = collections.OrderedDict()
+        values['__cache'] = cachec
+        cache_writer = _ConfigCacheWriter(cachec)
+        globs = globals()
+
+        def _visitor(path, val, parent_val, parent_key):
+            callback_name = '_validate_' + path.replace('/', '_')
+            callback = globs.get(callback_name)
+            if callback:
+                try:
+                    val2 = callback(val, values, cache_writer)
+                except Exception as ex:
+                    raise Exception("Error raised in validator '%s'." %
+                                    callback_name) from ex
+                if val2 is None:
+                    raise Exception("Validator '%s' isn't returning a "
+                                    "coerced value." % callback_name)
+                parent_val[parent_key] = val2
+
+        visit_dict(values, _visitor)
+
+        return values
+
+    def _generateDefaultContentModel(self, values):
+        dcmcopy = copy.deepcopy(default_content_model_base)
+        values = merge_dicts(dcmcopy, values)
+
+        blogsc = values['site'].get('blogs')
+        if blogsc is None:
+            blogsc = ['posts']
+            values['site']['blogs'] = blogsc
+
+        is_only_blog = (len(blogsc) == 1)
+        for blog_name in blogsc:
+            blog_cfg = get_default_content_model_for_blog(
+                    blog_name, is_only_blog, values)
+            values = merge_dicts(blog_cfg, values)
+
+        dcm = get_default_content_model(values)
+        values = merge_dicts(dcm, values)
+
+        return values
+
+
+class _ConfigCacheWriter(object):
+    def __init__(self, cache_dict):
+        self._cache_dict = cache_dict
+
+    def write(self, name, val):
+        logger.debug("Caching configuration item '%s' = %s" % (name, val))
+        self._cache_dict[name] = val
+
+
+default_configuration = collections.OrderedDict({
+        'site': collections.OrderedDict({
+            'title': "Untitled PieCrust website",
+            'root': '/',
+            'default_format': DEFAULT_FORMAT,
+            'default_template_engine': DEFAULT_TEMPLATE_ENGINE,
+            'enable_gzip': True,
+            'pretty_urls': False,
+            'trailing_slash': False,
+            'date_format': DEFAULT_DATE_FORMAT,
+            'auto_formats': collections.OrderedDict([
+                ('html', ''),
+                ('md', 'markdown'),
+                ('textile', 'textile')]),
+            'default_auto_format': 'md',
+            'default_pagination_source': None,
+            'pagination_suffix': '/%num%',
+            'slugify_mode': 'encode',
+            'themes_sources': [DEFAULT_THEME_SOURCE],
+            'cache_time': 28800,
+            'enable_debug_info': True,
+            'show_debug_info': False,
+            'use_default_content': True
+            }),
+        'baker': collections.OrderedDict({
+            'no_bake_setting': 'draft',
+            'workers': None,
+            'batch_size': None
+            })
+        })
+
+
+default_content_model_base = collections.OrderedDict({
+        'site': collections.OrderedDict({
+            'posts_fs': DEFAULT_POSTS_FS,
+            'date_format': DEFAULT_DATE_FORMAT,
+            'default_page_layout': 'default',
+            'default_post_layout': 'post',
+            'post_url': '%year%/%month%/%day%/%slug%',
+            'tag_url': 'tag/%tag%',
+            'category_url': '%category%',
+            'posts_per_page': 5
+            })
+        })
+
+
+def get_default_content_model(values):
+    default_layout = values['site']['default_page_layout']
+    return collections.OrderedDict({
+            'site': collections.OrderedDict({
+                'sources': collections.OrderedDict({
+                    'pages': {
+                        'type': 'default',
+                        'ignore_missing_dir': True,
+                        'data_endpoint': 'site.pages',
+                        'default_layout': default_layout,
+                        'item_name': 'page'
+                        }
+                    }),
+                'routes': [
+                    {
+                        'url': '/%path:slug%',
+                        'source': 'pages',
+                        'func': 'pcurl(slug)'
+                        }
+                    ],
+                'taxonomies': collections.OrderedDict({
+                    'tags': {
+                        'multiple': True,
+                        'term': 'tag'
+                        },
+                    'categories': {
+                        'term': 'category'
+                        }
+                    })
+                })
+            })
+
+
+def get_default_content_model_for_blog(blog_name, is_only_blog, values):
+    posts_fs = values['site']['posts_fs']
+    blog_cfg = values.get(blog_name, {})
+
+    if is_only_blog:
+        url_prefix = ''
+        tax_page_prefix = ''
+        fs_endpoint = 'posts'
+        data_endpoint = 'blog'
+        item_name = 'post'
+    else:
+        url_prefix = blog_name + '/'
+        tax_page_prefix = blog_name + '/'
+        fs_endpoint = 'posts/%s' % blog_name
+        data_endpoint = blog_name
+        item_name = '%s-post' % blog_name
+
+    items_per_page = blog_cfg.get(
+            'posts_per_page', values['site']['posts_per_page'])
+    date_format = blog_cfg.get(
+            'date_format', values['site']['date_format'])
+    default_layout = blog_cfg.get(
+            'default_layout', values['site']['default_post_layout'])
+
+    post_url = '/' + blog_cfg.get(
+            'post_url',
+            url_prefix + values['site']['post_url']).lstrip('/')
+    tag_url = '/' + blog_cfg.get(
+            'tag_url',
+            url_prefix + values['site']['tag_url']).lstrip('/')
+    category_url = '/' + blog_cfg.get(
+            'category_url',
+            url_prefix + values['site']['category_url']).lstrip('/')
+
+    return collections.OrderedDict({
+            'site': collections.OrderedDict({
+                'sources': collections.OrderedDict({
+                    blog_name: collections.OrderedDict({
+                        'type': 'posts/%s' % posts_fs,
+                        'fs_endpoint': fs_endpoint,
+                        'data_endpoint': data_endpoint,
+                        'item_name': item_name,
+                        'ignore_missing_dir': True,
+                        'data_type': 'blog',
+                        'items_per_page': items_per_page,
+                        'date_format': date_format,
+                        'default_layout': default_layout,
+                        'taxonomy_pages': collections.OrderedDict({
+                            'tags': ('pages:%s_tag.%%ext%%;'
+                                     'theme_pages:_tag.%%ext%%' %
+                                     tax_page_prefix),
+                            'categories': ('pages:%s_category.%%ext%%;'
+                                           'theme_pages:_category.%%ext%%' %
+                                           tax_page_prefix)
+                            })
+                        })
+                    }),
+                'routes': [
+                    {
+                        'url': post_url,
+                        'source': blog_name,
+                        'func': 'pcposturl(year,month,day,slug)'
+                        },
+                    {
+                        'url': tag_url,
+                        'source': blog_name,
+                        'taxonomy': 'tags',
+                        'func': 'pctagurl(tag)'
+                        },
+                    {
+                        'url': category_url,
+                        'source': blog_name,
+                        'taxonomy': 'categories',
+                        'func': 'pccaturl(category)'
+                        }
+                    ]
+                })
+            })
+
+
+# Configuration value validators.
+#
+# Make sure we have basic site stuff.
+def _validate_site(v, values, cache):
+    sources = v.get('sources')
+    if not sources:
+        raise ConfigurationError("No sources were defined.")
+    routes = v.get('routes')
+    if not routes:
+        raise ConfigurationError("No routes were defined.")
+    taxonomies = v.get('taxonomies')
+    if taxonomies is None:
+        v['taxonomies'] = {}
+    return v
+
+# Make sure the site root starts and ends with a slash.
+def _validate_site_root(v, values, cache):
+    if not v.startswith('/'):
+        raise ConfigurationError("The `site/root` setting must start "
+                                 "with a slash.")
+    root_url = urllib.parse.quote(v.rstrip('/') + '/')
+    return root_url
+
+
+# Cache auto-format regexes, check that `.html` is in there.
+def _validate_site_auto_formats(v, values, cache):
+    if not isinstance(v, dict):
+        raise ConfigurationError("The 'site/auto_formats' setting must be "
+                                 "a dictionary.")
+
+    v.setdefault('html', values['site']['default_format'])
+    auto_formats_re = r"\.(%s)$" % (
+            '|'.join(
+                    [re.escape(i) for i in list(v.keys())]))
+    cache.write('auto_formats_re', auto_formats_re)
+    return v
+
+
+# Check that the default auto-format is known.
+def _validate_site_default_auto_format(v, values, cache):
+    if v not in values['site']['auto_formats']:
+        raise ConfigurationError(
+                "Default auto-format '%s' is not declared." % v)
+    return v
+
+
+# Cache pagination suffix regex and format.
+def _validate_site_pagination_suffix(v, values, cache):
+    if len(v) == 0 or v[0] != '/':
+        raise ConfigurationError("The 'site/pagination_suffix' setting "
+                                 "must start with a slash.")
+    if '%num%' not in v:
+        raise ConfigurationError("The 'site/pagination_suffix' setting "
+                                 "must contain the '%num%' placeholder.")
+
+    pgn_suffix_fmt = v.replace('%num%', '%(num)d')
+    cache.write('pagination_suffix_format', pgn_suffix_fmt)
+
+    pgn_suffix_re = re.escape(v)
+    pgn_suffix_re = (pgn_suffix_re.replace("\\%num\\%", "(?P<num>\\d+)") +
+                     '$')
+    cache.write('pagination_suffix_re', pgn_suffix_re)
+    return v
+
+
+# Make sure theme sources is a list.
+def _validate_site_theme_sources(v, values, cache):
+    if not isinstance(v, list):
+        v = [v]
+    return v
+
+
+def _validate_site_sources(v, values, cache):
+    # Basic checks.
+    if not v:
+        raise ConfigurationError("There are no sources defined.")
+    if not isinstance(v, dict):
+        raise ConfigurationError("The 'site/sources' setting must be a "
+                                 "dictionary.")
+
+    # Add the theme page source if no sources were defined in the theme
+    # configuration itself.
+    has_any_theme_source = False
+    for sn, sc in v.items():
+        if sc.get('realm') == REALM_THEME:
+            has_any_theme_source = True
+            break
+    if not has_any_theme_source:
+        v['theme_pages'] = {
+                'theme_source': True,
+                'fs_endpoint': 'pages',
+                'data_endpoint': 'site/pages',
+                'item_name': 'page',
+                'realm': REALM_THEME}
+        values['site']['routes'].append({
+                'url': '/%path:slug%',
+                'source': 'theme_pages',
+                'func': 'pcurl(slug)'})
+
+    # Sources have the `default` scanner by default, duh. Also, a bunch
+    # of other default values for other configuration stuff.
+    reserved_endpoints = set(['piecrust', 'site', 'page', 'route',
+                              'assets', 'pagination', 'siblings',
+                              'family'])
+    for sn, sc in v.items():
+        if not isinstance(sc, dict):
+            raise ConfigurationError("All sources in 'site/sources' must "
+                                     "be dictionaries.")
+        sc.setdefault('type', 'default')
+        sc.setdefault('fs_endpoint', sn)
+        sc.setdefault('ignore_missing_dir', False)
+        sc.setdefault('data_endpoint', sn)
+        sc.setdefault('data_type', 'iterator')
+        sc.setdefault('item_name', sn)
+        sc.setdefault('items_per_page', 5)
+        sc.setdefault('date_format', DEFAULT_DATE_FORMAT)
+        sc.setdefault('realm', REALM_USER)
+
+        # Validate endpoints.
+        endpoint = sc['data_endpoint']
+        if endpoint in reserved_endpoints:
+            raise ConfigurationError(
+                    "Source '%s' is using a reserved endpoint name: %s" %
+                    (sn, endpoint))
+
+    return v
+
+
+def _validate_site_routes(v, values, cache):
+    if not v:
+        raise ConfigurationError("There are no routes defined.")
+    if not isinstance(v, list):
+        raise ConfigurationError("The 'site/routes' setting must be a "
+                                 "list.")
+
+    # Check routes are referencing correct sources, have default
+    # values, etc.
+    for rc in v:
+        if not isinstance(rc, dict):
+            raise ConfigurationError("All routes in 'site/routes' must be "
+                                     "dictionaries.")
+        rc_url = rc.get('url')
+        if not rc_url:
+            raise ConfigurationError("All routes in 'site/routes' must "
+                                     "have an 'url'.")
+        if rc_url[0] != '/':
+            raise ConfigurationError("Route URLs must start with '/'.")
+        if rc.get('source') is None:
+            raise ConfigurationError("Routes must specify a source.")
+        if rc['source'] not in list(values['site']['sources'].keys()):
+            raise ConfigurationError("Route is referencing unknown "
+                                     "source: %s" % rc['source'])
+        rc.setdefault('taxonomy', None)
+        rc.setdefault('page_suffix', '/%num%')
+
+    return v
+
+
+def _validate_site_taxonomies(v, values, cache):
+    for tn, tc in v.items():
+        tc.setdefault('multiple', False)
+        tc.setdefault('term', tn)
+        tc.setdefault('page', '_%s.%%ext%%' % tc['term'])
+
+    return v
+
+
+def _validate_site_plugins(v, values, cache):
+    if isinstance(v, str):
+        v = v.split(',')
+    elif not isinstance(v, list):
+        raise ConfigurationError(
+                "The 'site/plugins' setting must be an array, or a "
+                "comma-separated list.")
+    return v
+
--- a/piecrust/baking/single.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/baking/single.py	Mon Feb 22 22:50:30 2016 -0800
@@ -159,13 +159,15 @@
         if tax_info:
             ctx.setTaxonomyFilter(tax_info.term)
 
-        rp = render_page(ctx)
+        with self.app.env.timerScope("PageRender"):
+            rp = render_page(ctx)
 
-        out_dir = os.path.dirname(out_path)
-        _ensure_dir_exists(out_dir)
+        with self.app.env.timerScope("PageSerialize"):
+            out_dir = os.path.dirname(out_path)
+            _ensure_dir_exists(out_dir)
 
-        with codecs.open(out_path, 'w', 'utf8') as fp:
-            fp.write(rp.content)
+            with open(out_path, 'w', encoding='utf8') as fp:
+                fp.write(rp.content)
 
         return rp
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/commands/builtin/admin.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,109 @@
+import os
+import logging
+from piecrust import CACHE_DIR
+from piecrust.commands.base import ChefCommand
+
+
+logger = logging.getLogger(__name__)
+
+
+class AdministrationPanelCommand(ChefCommand):
+    def __init__(self):
+        super(AdministrationPanelCommand, self).__init__()
+        self.name = 'admin'
+        self.description = "Manages the PieCrust administration panel."
+        self.requires_website = False
+
+    def setupParser(self, parser, app):
+        subparsers = parser.add_subparsers()
+
+        p = subparsers.add_parser(
+                'init',
+                help="Creates a new administration panel website.")
+        p.set_defaults(sub_func=self._initFoodTruck)
+
+        p = subparsers.add_parser(
+                'genpass',
+                help=("Generates the hashed password for use as an "
+                      "admin password"))
+        p.add_argument('password', help="The password to hash.")
+        p.set_defaults(sub_func=self._generatePassword)
+
+        p = subparsers.add_parser(
+                'run',
+                help="Runs the administrative panel website.")
+        p.add_argument(
+                '-p', '--port',
+                help="The port for the administrative panel website.",
+                default=8090)
+        p.add_argument(
+                '-a', '--address',
+                help="The host for the administrative panel website.",
+                default='localhost')
+        p.set_defaults(sub_func=self._runFoodTruck)
+
+    def checkedRun(self, ctx):
+        if not hasattr(ctx.args, 'sub_func'):
+            return self._runFoodTruck(ctx)
+        return ctx.args.sub_func(ctx)
+
+    def _runFoodTruck(self, ctx):
+        from piecrust.processing.pipeline import ProcessorPipeline
+        out_dir = os.path.join(
+                ctx.app.root_dir, CACHE_DIR, 'foodtruck', 'server')
+        proc = ProcessorPipeline(ctx.app, out_dir)
+        proc.run()
+
+        from foodtruck import settings
+        settings.FOODTRUCK_CMDLINE_MODE = True
+        settings.FOODTRUCK_ROOT = ctx.app.root_dir
+        from foodtruck.main import run_foodtruck
+        run_foodtruck(
+                host=ctx.args.address,
+                port=ctx.args.port,
+                debug=ctx.args.debug)
+
+    def _initFoodTruck(self, ctx):
+        import getpass
+        import bcrypt
+
+        secret_key = os.urandom(22)
+        admin_username = input("Admin username (admin): ") or 'admin'
+        admin_password = getpass.getpass("Admin password: ")
+        if not admin_password:
+            logger.warning("No administration password set!")
+            logger.warning("Don't make this instance of FoodTruck public.")
+            logger.info("You can later set an admin password by editing "
+                        "the `foodtruck.yml` file and using the "
+                        "`chef admin genpass` command.")
+        else:
+            binpw = admin_password.encode('utf8')
+            hashpw = bcrypt.hashpw(binpw, bcrypt.gensalt()).decode('utf8')
+            admin_password = hashpw
+
+        ft_config = """
+security:
+    username: %(username)s
+    # You can generate another hashed password with `chef admin genpass`.
+    password: %(password)s
+"""
+        ft_config = ft_config % {
+                'username': admin_username,
+                'password': admin_password
+                }
+        with open('foodtruck.yml', 'w', encoding='utf8') as fp:
+            fp.write(ft_config)
+
+        flask_config = """
+SECRET_KEY = %(secret_key)s
+"""
+        flask_config = flask_config % {'secret_key': secret_key}
+        with open('app.cfg', 'w', encoding='utf8') as fp:
+            fp.write(flask_config)
+
+    def _generatePassword(self, ctx):
+        from foodtruck import bcryptfallback as bcrypt
+        binpw = ctx.args.password.encode('utf8')
+        hashpw = bcrypt.hashpw(binpw, bcrypt.gensalt()).decode('utf8')
+        logger.info(hashpw)
+
--- a/piecrust/commands/builtin/baking.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/commands/builtin/baking.py	Mon Feb 22 22:50:30 2016 -0800
@@ -5,6 +5,7 @@
 import fnmatch
 import datetime
 from colorama import Fore
+from piecrust import CACHE_DIR
 from piecrust.baking.baker import Baker
 from piecrust.baking.records import (
         BakeRecord, BakeRecordEntry, SubPageBakeInfo)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/commands/builtin/publishing.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,78 @@
+import logging
+import urllib.parse
+from piecrust.commands.base import ChefCommand
+from piecrust.publishing.publisher import Publisher, find_publisher_name
+
+
+logger = logging.getLogger(__name__)
+
+
+class PublishCommand(ChefCommand):
+    """ Command for running publish targets for the current site.
+    """
+    def __init__(self):
+        super(PublishCommand, self).__init__()
+        self.name = 'publish'
+        self.description = "Publishes you website to a specific target."
+
+    def setupParser(self, parser, app):
+        parser.add_argument(
+                '-l', '--list',
+                action='store_true',
+                help="List available publish targets for the current site.")
+        parser.add_argument(
+                '--log-publisher',
+                metavar='LOG_FILE',
+                help="Log the publisher's output to a given file.")
+        parser.add_argument(
+                '--preview',
+                action='store_true',
+                help="Only preview what the publisher would do.")
+        parser.add_argument(
+                'target',
+                nargs='?',
+                default='default',
+                help="The publish target to use.")
+
+    def run(self, ctx):
+        if ctx.args.list:
+            pub_cfg = ctx.app.config.get('publish')
+            if not pub_cfg:
+                logger.info("No available publish targets.")
+                return
+
+            for name, cfg in pub_cfg.items():
+                if isinstance(cfg, dict):
+                    pub_type = cfg.get('type')
+                    if pub_type:
+                        desc = cfg.get('description')
+                        bake_first = cfg.get('bake', True)
+                        msg = '%s (%s)' % (name, pub_type)
+                        if not bake_first:
+                            msg += ' (no local baking)'
+                        if desc:
+                            msg += ': ' + desc
+                        logger.info(msg)
+                    else:
+                        logger.error(
+                                "%s (unknown type '%s')" % (name, pub_type))
+                elif isinstance(cfg, str):
+                    comps = urllib.parse.urlparse(str(cfg))
+                    pub_name = find_publisher_name(ctx.app, comps.scheme)
+                    if pub_name:
+                        logger.info("%s (%s)" % (name, pub_name))
+                    else:
+                        logger.error(
+                                "%s (unknown scheme '%s')" %
+                                (name, comps.scheme))
+                else:
+                    logger.error(
+                            "%s (incorrect configuration)" % name)
+            return
+
+        pub = Publisher(ctx.app)
+        pub.run(
+                ctx.args.target,
+                preview=ctx.args.preview,
+                log_file=ctx.args.log_publisher)
+
--- a/piecrust/configuration.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/configuration.py	Mon Feb 22 22:50:30 2016 -0800
@@ -1,5 +1,6 @@
 import re
 import logging
+import collections
 import collections.abc
 import yaml
 from yaml.constructor import ConstructorError
@@ -69,7 +70,7 @@
 
     def setAll(self, values, validate=False):
         if validate:
-            self._validateAll(values)
+            values = self._validateAll(values)
         self._values = values
 
     def getAll(self):
@@ -129,11 +130,10 @@
 
 
 def merge_dicts(source, merging, validator=None, *args):
-    if validator is None:
-        validator = lambda k, v: v
     _recurse_merge_dicts(source, merging, None, validator)
     for other in args:
         _recurse_merge_dicts(source, other, None, validator)
+    return source
 
 
 def _recurse_merge_dicts(local_cur, incoming_cur, parent_path, validator):
@@ -149,9 +149,28 @@
             elif isinstance(v, list) and isinstance(local_v, list):
                 local_cur[k] = v + local_v
             else:
-                local_cur[k] = validator(key_path, v)
+                if validator is not None:
+                    v = validator(key_path, v)
+                local_cur[k] = v
         else:
-            local_cur[k] = validator(key_path, v)
+            if validator is not None:
+                v = validator(key_path, v)
+            local_cur[k] = v
+
+
+def visit_dict(subject, visitor):
+    _recurse_visit_dict(subject, None, visitor)
+
+
+def _recurse_visit_dict(cur, parent_path, visitor):
+    for k, v in cur.items():
+        key_path = k
+        if parent_path is not None:
+            key_path = parent_path + '/' + k
+
+        visitor(key_path, v, cur, k)
+        if isinstance(v, dict):
+            _recurse_visit_dict(v, key_path, visitor)
 
 
 header_regex = re.compile(
--- a/piecrust/data/debug.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/data/debug.py	Mon Feb 22 22:50:30 2016 -0800
@@ -309,9 +309,11 @@
             value = None
             render_name = name
             should_call = name in invoke_attrs
+            is_redirect = False
 
             if name in redirects:
                 name = redirects[name]
+                is_redirect = True
 
             query_instance = False
             try:
@@ -329,7 +331,10 @@
                 argcount = attr_func.__code__.co_argcount
                 var_names = attr_func.__code__.co_varnames
                 if argcount == 1 and should_call:
-                    render_name += '()'
+                    if not is_redirect:
+                        # Most of the time, redirects are for making a
+                        # property render better. So don't add parenthesis.
+                        render_name += '()'
                     value = attr_func()
                 else:
                     if should_call:
--- a/piecrust/data/iterators.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/data/iterators.py	Mon Feb 22 22:50:30 2016 -0800
@@ -1,5 +1,5 @@
 import logging
-from piecrust.data.filters import PaginationFilter
+from piecrust.data.filters import PaginationFilter, IsFilterClause, NotClause
 from piecrust.environment import AbortedSourceUseError
 from piecrust.events import Event
 from piecrust.sources.base import PageSource
@@ -132,6 +132,20 @@
             if src_it is not None:
                 self._pages = src_it
 
+        # If we're currently baking, apply the default baker filter
+        # to exclude things like draft posts.
+        if (isinstance(source, PageSource) and
+                source.app.config.get('baker/is_baking')):
+            setting_name = source.app.config.get('baker/no_bake_setting',
+                                                 'draft')
+            accessor = self._getSettingAccessor()
+            draft_filter = PaginationFilter(accessor)
+            draft_filter.root_clause = NotClause()
+            draft_filter.root_clause.addClause(
+                    IsFilterClause(setting_name, True))
+            self._simpleNonSortedWrap(
+                    PaginationFilterIterator, draft_filter)
+
         # Apply any filter first, before we start sorting or slicing.
         if pagination_filter is not None:
             self._simpleNonSortedWrap(PaginationFilterIterator,
--- a/piecrust/data/linker.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/data/linker.py	Mon Feb 22 22:50:30 2016 -0800
@@ -15,7 +15,13 @@
     """
     debug_render = ['parent', 'ancestors', 'siblings', 'children', 'root',
                     'forpath']
-    debug_render_invoke = ['parent', 'siblings', 'children']
+    debug_render_invoke = ['parent', 'ancestors', 'siblings', 'children',
+                           'root']
+    debug_render_redirect = {
+            'ancestors': '_debugRenderAncestors',
+            'siblings': '_debugRenderSiblings',
+            'children': '_debugRenderChildren',
+            'root': '_debugRenderRoot'}
 
     def __init__(self, source, page_path):
         self._source = source
@@ -84,6 +90,21 @@
         self._linker = Linker(self._source, dir_path,
                               root_page_path=self._root_page_path)
 
+    def _debugRenderAncestors(self):
+        return [i.name for i in self.ancestors]
+
+    def _debugRenderSiblings(self):
+        return [i.name for i in self.siblings]
+
+    def _debugRenderChildren(self):
+        return [i.name for i in self.children]
+
+    def _debugRenderRoot(self):
+        r = self.root
+        if r is not None:
+            return r.name
+        return None
+
 
 class LinkedPageData(PaginationData):
     """ Class whose instances get returned when iterating on a `Linker`
--- a/piecrust/data/paginator.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/data/paginator.py	Mon Feb 22 22:50:30 2016 -0800
@@ -208,7 +208,7 @@
         if self._pgn_filter is not None:
             f.addClause(self._pgn_filter.root_clause)
 
-        if isinstance(self._source, IPaginationSource):
+        if self._parent_page and isinstance(self._source, IPaginationSource):
             sf = self._source.getPaginationFilter(self._parent_page)
             if sf is not None:
                 f.addClause(sf.root_clause)
--- a/piecrust/main.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/main.py	Mon Feb 22 22:50:30 2016 -0800
@@ -1,7 +1,8 @@
+import os
+import os.path
 import io
 import sys
 import time
-import os.path
 import logging
 import argparse
 import colorama
@@ -74,26 +75,56 @@
     sys.exit(exit_code)
 
 
-class PreParsedChefArgs(object):
-    def __init__(self, root=None, cache=True, debug=False, quiet=False,
-                 log_file=None, log_debug=False, config_variant=None):
-        self.root = root
-        self.cache = cache
-        self.debug = debug
-        self.quiet = quiet
-        self.log_file = log_file
-        self.log_debug = log_debug
-        self.config_variant = config_variant
-        self.config_values = []
-        self.debug_only = []
-
-
-def _parse_config_value(arg):
-    try:
-        name, value = arg.split('=')
-    except Exception:
-        raise Exception("Invalid configuration name and value: %s" % arg)
-    return (name, value)
+def _setup_main_parser_arguments(parser):
+    parser.add_argument(
+            '--version',
+            action='version',
+            version=('%(prog)s ' + APP_VERSION))
+    parser.add_argument(
+            '--root',
+            help="The root directory of the website.")
+    parser.add_argument(
+            '--config',
+            dest='config_variant',
+            help="The configuration variant to use for this command.")
+    parser.add_argument(
+            '--config-set',
+            nargs=2,
+            metavar=('NAME', 'VALUE'),
+            action='append',
+            dest='config_values',
+            help="Sets a specific site configuration setting.")
+    parser.add_argument(
+            '--debug',
+            help="Show debug information.", action='store_true')
+    parser.add_argument(
+            '--debug-only',
+            nargs='*',
+            help="Only show debug information for the given categories.")
+    parser.add_argument(
+            '--no-cache',
+            help="When applicable, disable caching.",
+            action='store_true')
+    parser.add_argument(
+            '--quiet',
+            help="Print only important information.",
+            action='store_true')
+    parser.add_argument(
+            '--log',
+            dest='log_file',
+            help="Send log messages to the specified file.")
+    parser.add_argument(
+            '--log-debug',
+            help="Log debug messages to the log file.",
+            action='store_true')
+    parser.add_argument(
+            '--no-color',
+            help="Don't use colorized output.",
+            action='store_true')
+    parser.add_argument(
+            '--pid-file',
+            dest='pid_file',
+            help="Write a PID file for the current process.")
 
 
 def _pre_parse_chef_args(argv):
@@ -101,57 +132,28 @@
     # parser, because it can affect which plugins will be loaded. Also, log-
     # related arguments must be parsed first because we want to log everything
     # from the beginning.
-    res = PreParsedChefArgs()
-    i = 0
-    while i < len(argv):
-        arg = argv[i]
-        if arg.startswith('--root='):
-            res.root = os.path.expanduser(arg[len('--root='):])
-        elif arg == '--root':
-            res.root = os.path.expanduser(argv[i + 1])
-            i += 1
-        elif arg.startswith('--config='):
-            res.config_variant = arg[len('--config='):]
-        elif arg == '--config':
-            res.config_variant = argv[i + 1]
-            i += 1
-        elif arg.startswith('--config-set='):
-            res.config_values.append(
-                    _parse_config_value(arg[len('--config-set='):]))
-        elif arg == '--config-set':
-            res.config_values.append(_parse_config_value(argv[i + 1]))
-            i += 1
-        elif arg == '--log':
-            res.log_file = argv[i + 1]
-            i += 1
-        elif arg == '--log-debug':
-            res.log_debug = True
-        elif arg == '--debug-only':
-            res.debug_only.append(argv[i + 1])
-            i += 1
-        elif arg == '--no-cache':
-            res.cache = False
-        elif arg == '--debug':
-            res.debug = True
-        elif arg == '--quiet':
-            res.quiet = True
-        else:
-            break
-
-        i = i + 1
+    parser = argparse.ArgumentParser()
+    _setup_main_parser_arguments(parser)
+    parser.add_argument('args', nargs=argparse.REMAINDER)
+    res, _ = parser.parse_known_args(argv)
 
     # Setup the logger.
     if res.debug and res.quiet:
         raise Exception("You can't specify both --debug and --quiet.")
 
-    colorama.init()
+    strip_colors = None
+    if res.no_color:
+        strip_colors = True
+
+    colorama.init(strip=strip_colors)
     root_logger = logging.getLogger()
     root_logger.setLevel(logging.INFO)
     if res.debug or res.log_debug:
         root_logger.setLevel(logging.DEBUG)
 
-    for n in res.debug_only:
-        logging.getLogger(n).setLevel(logging.DEBUG)
+    if res.debug_only:
+        for n in res.debug_only:
+            logging.getLogger(n).setLevel(logging.DEBUG)
 
     log_handler = logging.StreamHandler(sys.stdout)
     if res.debug or res.debug_only:
@@ -171,14 +173,28 @@
         if res.log_debug:
             file_handler.setLevel(logging.DEBUG)
 
+    # PID file.
+    if res.pid_file:
+        try:
+            pid_file_dir = os.path.dirname(res.pid_file)
+            if pid_file_dir and not os.path.isdir(pid_file_dir):
+                os.makedirs(pid_file_dir)
+
+            with open(res.pid_file, 'w') as fp:
+                fp.write(str(os.getpid()))
+        except OSError as ex:
+            raise Exception("Can't write PID file: %s" % res.pid_file) from ex
+
     return res
 
 
 def _run_chef(pre_args, argv):
     # Setup the app.
     start_time = time.perf_counter()
-    root = pre_args.root
-    if root is None:
+    root = None
+    if pre_args.root:
+        root = os.path.expanduser(pre_args.root)
+    else:
         try:
             root = find_app_root()
         except SiteNotFoundError:
@@ -187,62 +203,33 @@
     if not root:
         app = NullPieCrust()
     else:
-        app = PieCrust(root, cache=pre_args.cache, debug=pre_args.debug)
+        app = PieCrust(root, cache=(not pre_args.no_cache),
+                       debug=pre_args.debug)
 
     # Build a hash for a custom cache directory.
     cache_key = 'default'
 
     # Handle custom configurations.
-    if pre_args.config_variant is not None and not root:
-        raise SiteNotFoundError("Can't apply any variant.")
+    if (pre_args.config_variant or pre_args.config_values) and not root:
+        raise SiteNotFoundError(
+                "Can't apply any configuration variant or value overrides, "
+                "there is no website here.")
     apply_variant_and_values(app, pre_args.config_variant,
                              pre_args.config_values)
 
     # Adjust the cache key.
     if pre_args.config_variant is not None:
         cache_key += ',variant=%s' % pre_args.config_variant
-    for name, value in pre_args.config_values:
-        cache_key += ',%s=%s' % (name, value)
+    if pre_args.config_values:
+        for name, value in pre_args.config_values:
+            cache_key += ',%s=%s' % (name, value)
 
     # Setup the arg parser.
     parser = argparse.ArgumentParser(
             prog='chef',
             description="The PieCrust chef manages your website.",
             formatter_class=argparse.RawDescriptionHelpFormatter)
-    parser.add_argument(
-            '--version',
-            action='version',
-            version=('%(prog)s ' + APP_VERSION))
-    parser.add_argument(
-            '--root',
-            help="The root directory of the website.")
-    parser.add_argument(
-            '--config',
-            help="The configuration variant to use for this command.")
-    parser.add_argument(
-            '--config-set',
-            help="Sets a specific site configuration setting.")
-    parser.add_argument(
-            '--debug',
-            help="Show debug information.", action='store_true')
-    parser.add_argument(
-            '--debug-only',
-            help="Only show debug information for the given categories.")
-    parser.add_argument(
-            '--no-cache',
-            help="When applicable, disable caching.",
-            action='store_true')
-    parser.add_argument(
-            '--quiet',
-            help="Print only important information.",
-            action='store_true')
-    parser.add_argument(
-            '--log',
-            help="Send log messages to the specified file.")
-    parser.add_argument(
-            '--log-debug',
-            help="Log debug messages to the log file.",
-            action='store_true')
+    _setup_main_parser_arguments(parser)
 
     commands = sorted(app.plugin_loader.getCommands(),
                       key=lambda c: c.name)
--- a/piecrust/osutil.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/osutil.py	Mon Feb 22 22:50:30 2016 -0800
@@ -9,7 +9,11 @@
 glob = _system_glob.glob
 
 
-if sys.platform == 'darwin':
+def _wrap_fs_funcs():
+    global walk
+    global listdir
+    global glob
+
     def _walk(top, **kwargs):
         for dirpath, dirnames, filenames in os.walk(top, **kwargs):
             dirpath = _from_osx_fs(dirpath)
@@ -33,9 +37,11 @@
     def _to_osx_fs(s):
         return unicodedata.ucd_3_2_0.normalize('NFD', s)
 
-    global walk, listdir, glob
-
     walk = _walk
     listdir = _listdir
     glob = _glob
 
+
+if sys.platform == 'darwin':
+    _wrap_fs_funcs()
+
--- a/piecrust/page.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/page.py	Mon Feb 22 22:50:30 2016 -0800
@@ -43,7 +43,7 @@
         self.source_metadata = source_metadata
         self.rel_path = rel_path
         self._config = None
-        self._raw_content = None
+        self._segments = None
         self._flags = FLAG_NONE
         self._datetime = None
 
@@ -74,9 +74,9 @@
         return self._config
 
     @property
-    def raw_content(self):
+    def segments(self):
         self._load()
-        return self._raw_content
+        return self._segments
 
     @property
     def datetime(self):
@@ -123,7 +123,7 @@
         self._datetime = value
 
     def getSegment(self, name='content'):
-        return self.raw_content[name]
+        return self.segments[name]
 
     def _load(self):
         if self._config is not None:
@@ -135,7 +135,7 @@
             config.merge(self.source_metadata['config'])
 
         self._config = config
-        self._raw_content = content
+        self._segments = content
         if was_cache_valid:
             self._flags |= FLAG_RAW_CACHE_VALID
 
--- a/piecrust/plugins/base.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/plugins/base.py	Mon Feb 22 22:50:30 2016 -0800
@@ -33,6 +33,9 @@
     def getSources(self):
         return []
 
+    def getPublishers(self):
+        return []
+
     def initialize(self, app):
         pass
 
@@ -83,6 +86,9 @@
     def getSources(self):
         return self._getPluginComponents('getSources')
 
+    def getPublishers(self):
+        return self._getPluginComponents('getPublishers')
+
     def _ensureLoaded(self):
         if self._plugins is not None:
             return
--- a/piecrust/plugins/builtin.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/plugins/builtin.py	Mon Feb 22 22:50:30 2016 -0800
@@ -1,10 +1,12 @@
 from piecrust.commands.base import HelpCommand
+from piecrust.commands.builtin.admin import AdministrationPanelCommand
 from piecrust.commands.builtin.baking import (
         BakeCommand, ShowRecordCommand)
 from piecrust.commands.builtin.info import (
         RootCommand, ShowConfigCommand,
         FindCommand, ShowSourcesCommand, ShowRoutesCommand, ShowPathsCommand)
 from piecrust.commands.builtin.plugins import PluginsCommand
+from piecrust.commands.builtin.publishing import PublishCommand
 from piecrust.commands.builtin.scaffolding import (
         PrepareCommand,
         DefaultPrepareTemplatesCommandExtension,
@@ -32,6 +34,8 @@
 from piecrust.processing.sass import SassProcessor
 from piecrust.processing.sitemap import SitemapProcessor
 from piecrust.processing.util import ConcatProcessor
+from piecrust.publishing.shell import ShellCommandPublisher
+from piecrust.publishing.rsync import RsyncPublisher
 from piecrust.sources.default import DefaultPageSource
 from piecrust.sources.posts import (
         FlatPostsSource, ShallowPostsSource, HierarchyPostsSource)
@@ -62,7 +66,9 @@
                 PluginsCommand(),
                 BakeCommand(),
                 ShowRecordCommand(),
-                ServeCommand()]
+                ServeCommand(),
+                AdministrationPanelCommand(),
+                PublishCommand()]
 
     def getCommandExtensions(self):
         return [
@@ -115,3 +121,8 @@
                 JekyllImporter(),
                 WordpressXmlImporter()]
 
+    def getPublishers(self):
+        return [
+                ShellCommandPublisher,
+                RsyncPublisher]
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/publishing/base.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,103 @@
+import os.path
+import shlex
+import logging
+import threading
+import subprocess
+
+
+logger = logging.getLogger(__name__)
+
+
+class PublishingContext(object):
+    def __init__(self):
+        self.bake_out_dir = None
+        self.preview = False
+
+
+class Publisher(object):
+    def __init__(self, app, target):
+        self.app = app
+        self.target = target
+        self.parsed_url = None
+        self.log_file_path = None
+
+    @property
+    def has_url_config(self):
+        return self.parsed_url is not None
+
+    @property
+    def url_config(self):
+        if self.parsed_url is not None:
+            return self.getConfig()
+        raise Exception("This publisher has a full configuration.")
+
+    def getConfig(self):
+        return self.app.config.get('publish/%s' % self.target)
+
+    def getConfigValue(self, name):
+        if self.has_url_config:
+            raise Exception("This publisher only has a URL configuration.")
+        return self.app.config.get('publish/%s/%s' % (self.target, name))
+
+    def run(self, ctx):
+        raise NotImplementedError()
+
+
+class ShellCommandPublisherBase(Publisher):
+    def __init__(self, app, target):
+        super(ShellCommandPublisherBase, self).__init__(app, target)
+        self.expand_user_args = True
+
+    def run(self, ctx):
+        args = self._getCommandArgs(ctx)
+        if self.expand_user_args:
+            args = [os.path.expanduser(i) for i in args]
+
+        if ctx.preview:
+            preview_args = ' '.join([shlex.quote(i) for i in args])
+            logger.info(
+                    "Would run shell command: %s" % preview_args)
+            return True
+
+        logger.debug(
+                "Running shell command: %s" % args)
+
+        proc = subprocess.Popen(
+                args, cwd=self.app.root_dir, bufsize=0,
+                stdout=subprocess.PIPE)
+
+        logger.debug("Running publishing monitor for PID %d" % proc.pid)
+        thread = _PublishThread(proc)
+        thread.start()
+        proc.wait()
+        thread.join()
+
+        if proc.returncode != 0:
+            logger.error(
+                    "Publish process returned code %d" % proc.returncode)
+        else:
+            logger.debug("Publish process returned successfully.")
+
+        return proc.returncode == 0
+
+    def _getCommandArgs(self, ctx):
+        raise NotImplementedError()
+
+
+class _PublishThread(threading.Thread):
+    def __init__(self, proc):
+        super(_PublishThread, self).__init__(
+                name='publish_monitor', daemon=True)
+        self.proc = proc
+        self.root_logger = logging.getLogger()
+
+    def run(self):
+        for line in iter(self.proc.stdout.readline, b''):
+            line_str = line.decode('utf8')
+            logger.info(line_str.rstrip('\r\n'))
+            for h in self.root_logger.handlers:
+                h.flush()
+
+        self.proc.communicate()
+        logger.debug("Publish monitor exiting.")
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/publishing/publisher.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,142 @@
+import os.path
+import time
+import logging
+import urllib.parse
+from piecrust.chefutil import format_timed
+from piecrust.publishing.base import PublishingContext
+
+
+logger = logging.getLogger(__name__)
+
+
+class InvalidPublishTargetError(Exception):
+    pass
+
+
+class PublishingError(Exception):
+    pass
+
+
+class Publisher(object):
+    def __init__(self, app):
+        self.app = app
+
+    def run(self, target, preview=False, log_file=None):
+        start_time = time.perf_counter()
+
+        # Get the configuration for this target.
+        target_cfg = self.app.config.get('publish/%s' % target)
+        if not target_cfg:
+            raise InvalidPublishTargetError(
+                    "No such publish target: %s" % target)
+
+        target_type = None
+        bake_first = True
+        parsed_url = None
+        if isinstance(target_cfg, dict):
+            target_type = target_cfg.get('type')
+            if not target_type:
+                raise InvalidPublishTargetError(
+                        "Publish target '%s' doesn't specify a type." % target)
+            bake_first = target_cfg.get('bake', True)
+        elif isinstance(target_cfg, str):
+            comps = urllib.parse.urlparse(target_cfg)
+            if not comps.scheme:
+                raise InvalidPublishTargetError(
+                        "Publish target '%s' has an invalid target URL." %
+                        target)
+            parsed_url = comps
+            target_type = find_publisher_name(self.app, comps.scheme)
+            if target_type is None:
+                raise InvalidPublishTargetError(
+                        "No such publish target scheme: %s" % comps.scheme)
+
+        # Setup logging stuff.
+        hdlr = None
+        root_logger = logging.getLogger()
+        if log_file and not preview:
+            logger.debug("Adding file handler for: %s" % log_file)
+            hdlr = logging.FileHandler(log_file, mode='w', encoding='utf8')
+            root_logger.addHandler(hdlr)
+        if not preview:
+            logger.info("Deploying to %s" % target)
+        else:
+            logger.info("Previewing deployment to %s" % target)
+
+        # Bake first is necessary.
+        bake_out_dir = None
+        if bake_first:
+            bake_out_dir = os.path.join(self.app.cache_dir, 'pub', target)
+            if not preview:
+                bake_start_time = time.perf_counter()
+                logger.debug("Baking first to: %s" % bake_out_dir)
+
+                from piecrust.baking.baker import Baker
+                baker = Baker(self.app, bake_out_dir)
+                rec1 = baker.bake()
+
+                from piecrust.processing.pipeline import ProcessorPipeline
+                proc = ProcessorPipeline(self.app, bake_out_dir)
+                rec2 = proc.run()
+
+                if not rec1.success or not rec2.success:
+                    raise Exception(
+                            "Error during baking, aborting publishing.")
+                logger.info(format_timed(bake_start_time, "Baked website."))
+            else:
+                logger.info("Would bake to: %s" % bake_out_dir)
+
+        # Create the appropriate publisher.
+        pub = None
+        for pub_cls in self.app.plugin_loader.getPublishers():
+            if pub_cls.PUBLISHER_NAME == target_type:
+                pub = pub_cls(self.app, target)
+                break
+        if pub is None:
+            raise InvalidPublishTargetError(
+                    "Publish target '%s' has invalid type: %s" %
+                    (target, target_type))
+        pub.parsed_url = parsed_url
+
+        # Publish!
+        logger.debug(
+                "Running publish target '%s' with publisher: %s" %
+                (target, pub.PUBLISHER_NAME))
+        pub_start_time = time.perf_counter()
+
+        ctx = PublishingContext()
+        ctx.bake_out_dir = bake_out_dir
+        ctx.preview = preview
+        try:
+            success = pub.run(ctx)
+        except Exception as ex:
+            raise PublishingError(
+                    "Error publishing to target: %s" % target) from ex
+        finally:
+            if hdlr:
+                root_logger.removeHandler(hdlr)
+                hdlr.close()
+
+        if not success:
+            raise PublishingError(
+                    "Unknown error publishing to target: %s" % target)
+        logger.info(format_timed(
+            pub_start_time, "Ran publisher %s" % pub.PUBLISHER_NAME))
+
+        logger.info(format_timed(start_time, 'Deployed to %s' % target))
+
+
+def find_publisher_class(app, scheme):
+    for pub_cls in app.plugin_loader.getPublishers():
+        pub_sch = getattr(pub_cls, 'PUBLISHER_SCHEME', None)
+        if ('bake+%s' % pub_sch) == scheme:
+            return pub_cls
+    return None
+
+
+def find_publisher_name(app, scheme):
+    pub_cls = find_publisher_class(app, scheme)
+    if pub_cls:
+        return pub_cls.PUBLISHER_NAME
+    return None
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/publishing/rsync.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,25 @@
+from piecrust.publishing.base import ShellCommandPublisherBase
+
+
+class RsyncPublisher(ShellCommandPublisherBase):
+    PUBLISHER_NAME = 'rsync'
+    PUBLISHER_SCHEME = 'rsync'
+
+    def _getCommandArgs(self, ctx):
+        if self.has_url_config:
+            orig = ctx.bake_out_dir
+            dest = self.parsed_url.netloc + self.parsed_url.path
+        else:
+            orig = self.getConfigValue('source', ctx.bake_our_dir)
+            dest = self.getConfigValue('destination')
+
+        rsync_options = None
+        if not self.has_url_config:
+            rsync_options = self.getConfigValue('options')
+        if rsync_options is None:
+            rsync_options = ['-avc', '--delete']
+
+        args = ['rsync'] + rsync_options
+        args += [orig, dest]
+        return args
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/publishing/shell.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,15 @@
+import shlex
+from piecrust.publishing.base import ShellCommandPublisherBase
+
+
+class ShellCommandPublisher(ShellCommandPublisherBase):
+    PUBLISHER_NAME = 'shell'
+
+    def _getCommandArgs(self, ctx):
+        target_cmd = self.getConfigValue('command')
+        if not target_cmd:
+            raise Exception("No command specified for publish target: %s" %
+                            self.target)
+        args = shlex.split(target_cmd)
+        return args
+
--- a/piecrust/rendering.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/rendering.py	Mon Feb 22 22:50:30 2016 -0800
@@ -234,7 +234,8 @@
     eis.pushPage(ctx.page, ctx)
     try:
         # Build the data for both segment and layout rendering.
-        page_data = _build_render_data(ctx)
+        with ctx.app.env.timerScope("BuildRenderData"):
+            page_data = _build_render_data(ctx)
 
         # Render content segments.
         ctx.setCurrentPass(PASS_FORMATTING)
@@ -242,16 +243,17 @@
         save_to_fs = True
         if ctx.app.env.fs_cache_only_for_main_page and not eis.is_main_page:
             save_to_fs = False
-        if repo and not ctx.force_render:
-            render_result = repo.get(
-                    ctx.uri,
-                    lambda: _do_render_page_segments(ctx.page, page_data),
-                    fs_cache_time=ctx.page.path_mtime,
-                    save_to_fs=save_to_fs)
-        else:
-            render_result = _do_render_page_segments(ctx.page, page_data)
-            if repo:
-                repo.put(ctx.uri, render_result, save_to_fs)
+        with ctx.app.env.timerScope("PageRenderSegments"):
+            if repo and not ctx.force_render:
+                render_result = repo.get(
+                        ctx.uri,
+                        lambda: _do_render_page_segments(ctx.page, page_data),
+                        fs_cache_time=ctx.page.path_mtime,
+                        save_to_fs=save_to_fs)
+            else:
+                render_result = _do_render_page_segments(ctx.page, page_data)
+                if repo:
+                    repo.put(ctx.uri, render_result, save_to_fs)
 
         # Render layout.
         page = ctx.page
@@ -261,8 +263,11 @@
             layout_name = page.source.config.get('default_layout', 'default')
         null_names = ['', 'none', 'nil']
         if layout_name not in null_names:
-            build_layout_data(page, page_data, render_result['segments'])
-            layout_result = _do_render_layout(layout_name, page, page_data)
+            with ctx.app.env.timerScope("BuildRenderData"):
+                build_layout_data(page, page_data, render_result['segments'])
+
+            with ctx.app.env.timerScope("PageRenderLayout"):
+                layout_result = _do_render_layout(layout_name, page, page_data)
         else:
             layout_result = {
                     'content': render_result['segments']['content'],
@@ -279,7 +284,7 @@
                 layout_result['pass_info'])
         return rp
     except Exception as ex:
-        page_rel_path = os.path.relpath(page.path, ctx.app.root_dir)
+        page_rel_path = os.path.relpath(ctx.page.path, ctx.app.root_dir)
         raise Exception("Error rendering page: %s" % page_rel_path) from ex
     finally:
         ctx.setCurrentPass(PASS_NONE)
@@ -344,7 +349,7 @@
     engine = get_template_engine(app, engine_name)
 
     formatted_segments = {}
-    for seg_name, seg in page.raw_content.items():
+    for seg_name, seg in page.segments.items():
         seg_text = ''
         for seg_part in seg.parts:
             part_format = seg_part.fmt or format_name
--- a/piecrust/resources/server/piecrust-debug-info.css	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/resources/server/piecrust-debug-info.css	Mon Feb 22 22:50:30 2016 -0800
@@ -38,9 +38,9 @@
 .piecrust-debug-window {
     padding: 1em;
     text-align: left;
-    font-family: serif;
+    font-family: sans-serif;
     font-style: normal;
-    font-size: 1rem;
+    font-size: 14px;
     font-weight: normal;
     background: #a42;
     color: #fff;
--- a/piecrust/serving/server.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/serving/server.py	Mon Feb 22 22:50:30 2016 -0800
@@ -72,11 +72,12 @@
 class Server(object):
     def __init__(self, root_dir,
                  debug=False, sub_cache_dir=None, enable_debug_info=True,
-                 static_preview=True):
+                 root_url='/', static_preview=True):
         self.root_dir = root_dir
         self.debug = debug
         self.sub_cache_dir = sub_cache_dir
         self.enable_debug_info = enable_debug_info
+        self.root_url = root_url
         self.static_preview = static_preview
         self._page_record = ServeRecord()
         self._out_dir = os.path.join(root_dir, CACHE_DIR, 'server')
@@ -109,14 +110,15 @@
 
         # Create the app for this request.
         app = get_app_for_server(self.root_dir, debug=self.debug,
-                                 sub_cache_dir=self.sub_cache_dir)
+                                 sub_cache_dir=self.sub_cache_dir,
+                                 root_url=self.root_url)
         if (app.config.get('site/enable_debug_info') and
                 self.enable_debug_info and
                 '!debug' in request.args):
             app.config.set('site/show_debug_info', True)
 
         # We'll serve page assets directly from where they are.
-        app.env.base_asset_url_format = '/_asset/%path%'
+        app.env.base_asset_url_format = self.root_url + '_asset/%path%'
 
         # Let's see if it can be a page asset.
         response = self._try_serve_page_asset(app, environ, request)
@@ -140,7 +142,8 @@
             raise InternalServerError(msg) from ex
 
     def _try_serve_asset(self, environ, request):
-        rel_req_path = request.path.lstrip('/').replace('/', os.sep)
+        offset = len(self.root_url)
+        rel_req_path = request.path[offset:].replace('/', os.sep)
         if request.path.startswith('/_cache/'):
             # Some stuff needs to be served directly from the cache directory,
             # like LESS CSS map files.
@@ -156,10 +159,11 @@
         return None
 
     def _try_serve_page_asset(self, app, environ, request):
-        if not request.path.startswith('/_asset/'):
+        if not request.path.startswith(self.root_url + '_asset/'):
             return None
 
-        full_path = os.path.join(app.root_dir, request.path[len('/_asset/'):])
+        offset = len(self.root_url + '_asset/')
+        full_path = os.path.join(app.root_dir, request.path[offset:])
         if not os.path.isfile(full_path):
             return None
 
--- a/piecrust/serving/util.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/serving/util.py	Mon Feb 22 22:50:30 2016 -0800
@@ -16,11 +16,12 @@
 logger = logging.getLogger(__name__)
 
 
-def get_app_for_server(root_dir, debug=False, sub_cache_dir=None):
+def get_app_for_server(root_dir, debug=False, sub_cache_dir=None,
+                       root_url='/'):
     app = PieCrust(root_dir=root_dir, debug=debug)
     if sub_cache_dir:
         app._useSubCacheDir(sub_cache_dir)
-    app.config.set('site/root', '/')
+    app.config.set('site/root', root_url)
     app.config.set('server/is_serving', True)
     return app
 
--- a/piecrust/sources/base.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/sources/base.py	Mon Feb 22 22:50:30 2016 -0800
@@ -109,6 +109,9 @@
     def buildPageFactories(self):
         raise NotImplementedError()
 
+    def buildPageFactory(self, path):
+        raise NotImplementedError()
+
     def resolveRef(self, ref_path):
         """ Returns the full path and source metadata given a source
             (relative) path, like a ref-spec.
--- a/piecrust/sources/default.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/sources/default.py	Mon Feb 22 22:50:30 2016 -0800
@@ -4,7 +4,9 @@
 from piecrust.sources.base import (
         PageFactory, PageSource, InvalidFileSystemEndpointError,
         MODE_CREATING)
-from piecrust.sources.interfaces import IListableSource, IPreparingSource
+from piecrust.sources.interfaces import (
+        IListableSource, IPreparingSource, IInteractiveSource,
+        InteractiveField)
 from piecrust.sources.mixins import SimplePaginationSourceMixin
 
 
@@ -21,7 +23,8 @@
             f not in ['Thumbs.db'])  # Windows bullshit
 
 
-class DefaultPageSource(PageSource, IListableSource, IPreparingSource,
+class DefaultPageSource(PageSource,
+                        IListableSource, IPreparingSource, IInteractiveSource,
                         SimplePaginationSourceMixin):
     SOURCE_NAME = 'default'
 
@@ -55,6 +58,17 @@
                 self._populateMetadata(fac_path, metadata)
                 yield PageFactory(self, fac_path, metadata)
 
+    def buildPageFactory(self, path):
+        if not path.startswith(self.fs_endpoint_path):
+            raise Exception("Page path '%s' isn't inside '%s'." % (
+                    path, self.fs_enpoint_path))
+        rel_path = path[len(self.fs_endpoint_path):].lstrip('\\/')
+        slug = self._makeSlug(rel_path)
+        metadata = {'slug': slug}
+        fac_path = rel_path.replace('\\', '/')
+        self._populateMetadata(fac_path, metadata)
+        return PageFactory(self, fac_path, metadata)
+
     def resolveRef(self, ref_path):
         path = os.path.normpath(
                 os.path.join(self.fs_endpoint_path, ref_path.lstrip("\\/")))
@@ -134,6 +148,11 @@
     def buildMetadata(self, args):
         return {'slug': args.uri}
 
+    def getInteractiveFields(self):
+        return [
+                InteractiveField('slug', InteractiveField.TYPE_STRING,
+                                 'new-page')]
+
     def _makeSlug(self, rel_path):
         slug, ext = os.path.splitext(rel_path)
         slug = slug.replace('\\', '/')
--- a/piecrust/sources/interfaces.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/sources/interfaces.py	Mon Feb 22 22:50:30 2016 -0800
@@ -23,7 +23,7 @@
         raise NotImplementedError()
 
 
-class IListableSource:
+class IListableSource(object):
     """ Defines the interface for a source that can be iterated on in a
         hierarchical manner, for use with the `family` data endpoint.
     """
@@ -37,7 +37,7 @@
         raise NotImplementedError()
 
 
-class IPreparingSource:
+class IPreparingSource(object):
     """ Defines the interface for a source whose pages can be created by the
         `chef prepare` command.
     """
@@ -48,3 +48,17 @@
         raise NotImplementedError()
 
 
+class InteractiveField(object):
+    TYPE_STRING = 0
+    TYPE_INT = 1
+
+    def __init__(self, name, field_type, default_value):
+        self.name = name
+        self.field_type = field_type
+        self.default_value = default_value
+
+
+class IInteractiveSource(object):
+    def getInteractiveFields(self):
+        raise NotImplementedError()
+
--- a/piecrust/sources/posts.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/piecrust/sources/posts.py	Mon Feb 22 22:50:30 2016 -0800
@@ -7,14 +7,17 @@
 from piecrust.sources.base import (
         PageSource, InvalidFileSystemEndpointError, PageFactory,
         MODE_CREATING, MODE_PARSING)
-from piecrust.sources.interfaces import IPreparingSource
+from piecrust.sources.interfaces import (
+        IPreparingSource, IInteractiveSource, InteractiveField)
 from piecrust.sources.mixins import SimplePaginationSourceMixin
+from piecrust.uriutil import multi_replace
 
 
 logger = logging.getLogger(__name__)
 
 
-class PostsSource(PageSource, IPreparingSource, SimplePaginationSourceMixin):
+class PostsSource(PageSource, IPreparingSource, IInteractiveSource,
+                  SimplePaginationSourceMixin):
     PATH_FORMAT = None
 
     def __init__(self, app, name, config):
@@ -33,6 +36,36 @@
         metadata = self._parseMetadataFromPath(ref_path)
         return path, metadata
 
+    def buildPageFactory(self, path):
+        if not path.startswith(self.fs_endpoint_path):
+            raise Exception("Page path '%s' isn't inside '%s'." % (
+                    path, self.fs_endpoint_path))
+        rel_path = path[len(self.fs_endpoint_path):].lstrip('\\/')
+        pat = self.PATH_FORMAT % {
+                'year': 'YEAR',
+                'month': 'MONTH',
+                'day': 'DAY',
+                'slug': 'SLUG',
+                'ext': 'EXT'}
+        pat = re.escape(pat)
+        pat = multi_replace(pat, {
+                'YEAR': '(\d{4})',
+                'MONTH': '(\d{2})',
+                'DAY': '(\d{2})',
+                'SLUG': '(.*)',
+                'EXT': '(.*)'})
+        m = re.match(pat, rel_path)
+        if m is None:
+            raise Exception("'%s' isn't a proper %s page path." % (
+                    rel_path, self.SOURCE_NAME))
+        return self._makeFactory(
+                rel_path,
+                m.group(4),
+                int(m.group(1)),
+                int(m.group(2)),
+                int(m.group(3)))
+
+
     def findPageFactory(self, metadata, mode):
         year = metadata.get('year')
         month = metadata.get('month')
@@ -98,7 +131,8 @@
         return PageFactory(self, rel_path, fac_metadata)
 
     def setupPrepareParser(self, parser, app):
-        parser.add_argument('-d', '--date', help="The date of the post, "
+        parser.add_argument(
+                '-d', '--date', help="The date of the post, "
                 "in `year/month/day` format (defaults to today).")
         parser.add_argument('slug', help="The URL slug for the new post.")
 
@@ -106,7 +140,7 @@
         dt = datetime.date.today()
         if args.date:
             if args.date == 'today':
-                pass # Keep the default we had.
+                pass  # Keep the default we had.
             elif args.date == 'tomorrow':
                 dt += datetime.timedelta(days=1)
             elif args.date.startswith('+'):
@@ -118,12 +152,21 @@
                 try:
                     year, month, day = [int(s) for s in args.date.split('/')]
                 except ValueError:
-                    raise Exception("Dates must be of the form: YEAR/MONTH/DAY.")
+                    raise Exception("Dates must be of the form: "
+                                    "YEAR/MONTH/DAY.")
                 dt = datetime.date(year, month, day)
 
         year, month, day = dt.year, dt.month, dt.day
         return {'year': year, 'month': month, 'day': day, 'slug': args.slug}
 
+    def getInteractiveFields(self):
+        dt = datetime.date.today()
+        return [
+            InteractiveField('year', InteractiveField.TYPE_INT, dt.year),
+            InteractiveField('month', InteractiveField.TYPE_INT, dt.month),
+            InteractiveField('day', InteractiveField.TYPE_INT, dt.day),
+            InteractiveField('slug', InteractiveField.TYPE_STRING, 'new-post')]
+
     def _checkFsEndpointPath(self):
         if not os.path.isdir(self.fs_endpoint_path):
             if self.ignore_missing_dir:
--- a/pytest.ini	Wed Dec 30 20:21:41 2015 +1300
+++ b/pytest.ini	Mon Feb 22 22:50:30 2016 -0800
@@ -1,3 +1,3 @@
 [pytest]
-norecursedirs = .* _darcs CVS .svn .git .hg *.egg-info dist build node_modules venv tmp* {args}
+norecursedirs = .* _darcs CVS .svn .git .hg *.egg-info dist build bower_components node_modules venv tmp* _cache {args}
 
--- a/requirements.txt	Wed Dec 30 20:21:41 2015 +1300
+++ b/requirements.txt	Mon Feb 22 22:50:30 2016 -0800
@@ -1,5 +1,8 @@
+cffi==1.5.0
 colorama==0.3.3
 compressinja==0.0.2
+Flask==0.10.1
+Flask-Login==0.3.2
 Jinja2==2.7.3
 Markdown==2.6.2
 MarkupSafe==0.23
--- a/setup.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/setup.py	Mon Feb 22 22:50:30 2016 -0800
@@ -199,7 +199,7 @@
             'Programming Language :: Python :: 3'
             ],
         entry_points={'console_scripts': [
-            'chef = piecrust.main:main',
+            'chef = piecrust.main:main'
             ]}
         )
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tasks.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,15 @@
+from invoke import Collection, task, run
+from garcon.benchsite import genbenchsite
+from garcon.changelog import genchangelog
+from garcon.documentation import gendocs
+from garcon.messages import genmessages
+from garcon.pypi import makerelease
+
+
+ns = Collection()
+ns.add_task(genbenchsite, name='benchsite')
+ns.add_task(genchangelog, name='changelog')
+ns.add_task(gendocs, name='docs')
+ns.add_task(genmessages, name='messages')
+ns.add_task(makerelease, name='release')
+
--- a/tests/bakes/test_unicode_tags.yaml	Wed Dec 30 20:21:41 2015 +1300
+++ b/tests/bakes/test_unicode_tags.yaml	Mon Feb 22 22:50:30 2016 -0800
@@ -38,8 +38,28 @@
       {% endfor %}
     pages/_index.md: ''
 outfiles:
+    tag/Это тэг.html: |
+      Pages in /tag/%D0%AD%D1%82%D0%BE%20%D1%82%D1%8D%D0%B3.html
+      Post 01
+---
+config:
+    site:
+        slugify_mode: lowercase,encode
+in:
+    posts/2015-03-01_post01.md: |
+      ---
+      title: Post 01
+      tags: [Это тэг]
+      ---
+    pages/_tag.md: |
+      Pages in {{pctagurl(tag)}}
+      {% for p in pagination.posts -%}
+      {{p.title}}
+      {% endfor %}
+    pages/_index.md: ''
+outfiles:
     tag/это тэг.html: |
-      Pages in /tag/%D0%AD%D1%82%D0%BE%20%D1%82%D1%8D%D0%B3.html
+      Pages in /tag/%D1%8D%D1%82%D0%BE%20%D1%82%D1%8D%D0%B3.html
       Post 01
 ---
 config:
--- a/tests/conftest.py	Wed Dec 30 20:21:41 2015 +1300
+++ b/tests/conftest.py	Mon Feb 22 22:50:30 2016 -0800
@@ -156,9 +156,9 @@
             hdl = logging.StreamHandler(stream=memstream)
             logging.getLogger().addHandler(hdl)
             try:
-                from piecrust.main import PreParsedChefArgs, _run_chef
-                pre_args = PreParsedChefArgs(
-                        root=fs.path('/kitchen'))
+                from piecrust.main import _pre_parse_chef_args, _run_chef
+                pre_args = _pre_parse_chef_args([
+                        '--root', fs.path('/kitchen')])
                 exit_code = _run_chef(pre_args, argv)
             finally:
                 logging.getLogger().removeHandler(hdl)
@@ -449,13 +449,32 @@
 
     test_time_iso8601 = time.strftime('%Y-%m-%dT%H:%M:%SZ',
                                       time.gmtime(ctx.time))
-    replacements = {
-            '%test_time_iso8601%': test_time_iso8601}
-    for token, repl in replacements.items():
-        left = left.replace(token, repl)
-        right = right.replace(token, repl)
+    test_time_iso8601_pattern = '%test_time_iso8601%'
+
+    left_time_indices = []
+    i = -1
+    while True:
+        i = left.find(test_time_iso8601_pattern, i + 1)
+        if i >= 0:
+            left_time_indices.append(i)
+            left = (left[:i] + test_time_iso8601 +
+                    left[i + len(test_time_iso8601_pattern):])
+        else:
+            break
 
     for i in range(min(len(left), len(right))):
+        if i in left_time_indices:
+            # This is where the time starts. Let's compare that the time
+            # values are within a few seconds of each other (usually 0 or 1).
+            right_time_str = right[i:i + len(test_time_iso8601)]
+            right_time = time.strptime(right_time_str, '%Y-%m-%dT%H:%M:%SZ')
+            left_time = time.gmtime(ctx.time)
+            difference = time.mktime(left_time) - time.mktime(right_time)
+            print("Got time difference: %d" % difference)
+            if abs(difference) <= 2:
+                print("(good enough, moving to end of timestamp)")
+                i += len(test_time_iso8601)
+
         if left[i] != right[i]:
             start = max(0, i - 15)
             l_end = min(len(left), i + 15)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_appconfig.py	Mon Feb 22 22:50:30 2016 -0800
@@ -0,0 +1,35 @@
+from piecrust.appconfig import PieCrustConfiguration
+
+
+def test_config_default():
+    values = {}
+    config = PieCrustConfiguration(values=values)
+    assert config.get('site/root') == '/'
+    assert len(config.get('site/sources')) == 3  # pages, posts, theme_pages
+
+
+def test_config_default2():
+    config = PieCrustConfiguration()
+    assert config.get('site/root') == '/'
+    assert len(config.get('site/sources')) == 3  # pages, posts, theme_pages
+
+
+def test_config_site_override_title():
+    values = {'site': {'title': "Whatever"}}
+    config = PieCrustConfiguration(values=values)
+    assert config.get('site/root') == '/'
+    assert config.get('site/title') == 'Whatever'
+
+
+def test_config_site_add_source():
+    values = {'site': {
+        'sources': {'notes': {}},
+        'routes': [{'url': '/notes/%path:slug%', 'source': 'notes'}]
+        }}
+    config = PieCrustConfiguration(values=values)
+    # The order of routes is important. Sources, not so much.
+    assert list(map(lambda v: v['source'], config.get('site/routes'))) == [
+            'notes', 'posts', 'posts', 'posts', 'pages', 'theme_pages']
+    assert list(config.get('site/sources').keys()) == [
+            'pages', 'posts', 'notes', 'theme_pages']
+
--- a/util/changelog/category_title.rst	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-
-1.%sub_num% %category%
-----------------------
-
--- a/util/changelog/header.rst	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-
-#########
-CHANGELOG
-#########
-
-This is the changelog for PieCrust_.
-
-.. _PieCrust: http://bolt80.com/piecrust/
-
-
--- a/util/changelog/version_title.rst	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-
-==================================
-%num%. PieCrust %version% (%date%)
-==================================
-
--- a/util/generate_benchsite.py	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,215 +0,0 @@
-import io
-import os
-import os.path
-import string
-import random
-import datetime
-import argparse
-
-
-def generateWord(min_len=1, max_len=10):
-    length = random.randint(min_len, max_len)
-    word = ''.join(random.choice(string.ascii_letters) for _ in range(length))
-    return word
-
-
-def generateSentence(words):
-    return ' '.join([generateWord() for i in range(words)])
-
-
-def generateDate():
-    year = random.choice(range(1995, 2015))
-    month = random.choice(range(1, 13))
-    day = random.choice(range(1, 29))
-    hours = random.choice(range(0, 24))
-    minutes = random.choice(range(0, 60))
-    seconds = random.choice(range(0, 60))
-    return datetime.datetime(
-            year, month, day, hours, minutes, seconds)
-
-
-def generateTitleAndSlug():
-    title = generateSentence(8)
-    slug = title.replace(' ', '-').lower()
-    slug = ''.join(c for c in slug if c.isalnum() or c == '-')
-    return title, slug
-
-
-class BenchmarkSiteGenerator(object):
-    def __init__(self, out_dir):
-        self.out_dir = out_dir
-        self.all_tags = []
-
-    def generatePost(self):
-        post_info = {}
-        title, slug = generateTitleAndSlug()
-        post_info.update({
-            'title': title,
-            'slug': slug})
-        post_info['description'] = generateSentence(20)
-        post_info['tags'] = random.choice(self.all_tags)
-        post_info['datetime'] = generateDate()
-
-        buf = io.StringIO()
-        with buf:
-            para_count = random.randint(5, 10)
-            for i in range(para_count):
-                buf.write(generateSentence(random.randint(50, 100)))
-                buf.write('\n\n')
-            post_info['text'] = buf.getvalue()
-
-        self.writePost(post_info)
-
-    def initialize(self):
-        pass
-
-    def writePost(self, post_info):
-        raise NotImplementedError()
-
-
-class PieCrustBechmarkSiteGenerator(BenchmarkSiteGenerator):
-    def initialize(self):
-        posts_dir = os.path.join(self.out_dir, 'posts')
-        if not os.path.isdir(posts_dir):
-            os.makedirs(posts_dir)
-
-    def writePost(self, post_info):
-        out_dir = os.path.join(self.out_dir, 'posts')
-        slug = post_info['slug']
-        dtstr = post_info['datetime'].strftime('%Y-%m-%d')
-        with open('%s/%s_%s.md' % (out_dir, dtstr, slug), 'w',
-                  encoding='utf8') as f:
-            f.write('---\n')
-            f.write('title: %s\n' % post_info['title'])
-            f.write('description: %s\n' % post_info['description'])
-            f.write('tags: [%s]\n' % post_info['tags'])
-            f.write('---\n')
-
-            para_count = random.randint(5, 10)
-            for i in range(para_count):
-                f.write(generateSentence(random.randint(50, 100)))
-                f.write('\n\n')
-
-
-class OctopressBenchmarkSiteGenerator(BenchmarkSiteGenerator):
-    def initialize(self):
-        posts_dir = os.path.join(self.out_dir, 'source', '_posts')
-        if not os.path.isdir(posts_dir):
-            os.makedirs(posts_dir)
-
-    def writePost(self, post_info):
-        out_dir = os.path.join(self.out_dir, 'source', '_posts')
-        slug = post_info['slug']
-        dtstr = post_info['datetime'].strftime('%Y-%m-%d')
-        with open('%s/%s-%s.markdown' % (out_dir, dtstr, slug), 'w',
-                  encoding='utf8') as f:
-            f.write('---\n')
-            f.write('layout: post\n')
-            f.write('title: %s\n' % post_info['title'])
-            f.write('date: %s 12:00\n' % dtstr)
-            f.write('comments: false\n')
-            f.write('categories: [%s]\n' % post_info['tags'])
-            f.write('---\n')
-
-            para_count = random.randint(5, 10)
-            for i in range(para_count):
-                f.write(generateSentence(random.randint(50, 100)))
-                f.write('\n\n')
-
-
-class MiddlemanBenchmarkSiteGenerator(BenchmarkSiteGenerator):
-    def initialize(self):
-        posts_dir = os.path.join(self.out_dir, 'source')
-        if not os.path.isdir(posts_dir):
-            os.makedirs(posts_dir)
-
-    def writePost(self, post_info):
-        out_dir = os.path.join(self.out_dir, 'source')
-        slug = post_info['slug']
-        dtstr = post_info['datetime'].strftime('%Y-%m-%d')
-        with open('%s/%s-%s.html.markdown' % (out_dir, dtstr, slug), 'w',
-                  encoding='utf8') as f:
-            f.write('---\n')
-            f.write('title: %s\n' % post_info['title'])
-            f.write('date: %s\n' % post_info['datetime'].strftime('%Y/%m/%d'))
-            f.write('tags: %s\n' % post_info['tags'])
-            f.write('---\n')
-
-            para_count = random.randint(5, 10)
-            for i in range(para_count):
-                f.write(generateSentence(random.randint(50, 100)))
-                f.write('\n\n')
-
-
-class HugoBenchmarkSiteGenerator(BenchmarkSiteGenerator):
-    def initialize(self):
-        posts_dir = os.path.join(self.out_dir, 'content', 'post')
-        if not os.path.isdir(posts_dir):
-            os.makedirs(posts_dir)
-
-    def writePost(self, post_info):
-        out_dir = os.path.join(self.out_dir, 'content', 'post')
-        dtstr = post_info['datetime'].strftime('%Y-%m-%d_%H-%M-%S')
-        post_path = os.path.join(out_dir, '%s.md' % dtstr)
-        with open(post_path, 'w', encoding='utf8') as f:
-            f.write('+++\n')
-            f.write('title = "%s"\n' % post_info['title'])
-            f.write('description = "%s"\n' % post_info['description'])
-            f.write('categories = [\n  "%s"\n]\n' % post_info['tags'])
-            f.write('date = "%s"\n' % post_info['datetime'].strftime(
-                    "%Y-%m-%d %H:%M:%S-00:00"))
-            f.write('slug ="%s"\n' % post_info['slug'])
-            f.write('+++\n')
-            f.write(post_info['text'])
-
-
-generators = {
-        'piecrust': PieCrustBechmarkSiteGenerator,
-        'octopress': OctopressBenchmarkSiteGenerator,
-        'middleman': MiddlemanBenchmarkSiteGenerator,
-        'hugo': HugoBenchmarkSiteGenerator
-        }
-
-
-def main():
-    parser = argparse.ArgumentParser(
-            prog='generate_benchsite',
-            description=("Generates a benchmark website with placeholder "
-                         "content suitable for testing."))
-    parser.add_argument(
-            'engine',
-            help="The engine to generate the site for.",
-            choices=list(generators.keys()))
-    parser.add_argument(
-            'out_dir',
-            help="The target directory for the website.")
-    parser.add_argument(
-            '-c', '--post-count',
-            help="The number of posts to create.",
-            type=int,
-            default=100)
-    parser.add_argument(
-            '--tag-count',
-            help="The number of tags to use.",
-            type=int,
-            default=30)
-
-    result = parser.parse_args()
-
-    print("Generating %d posts in %s..." % (
-            result.post_count, result.out_dir))
-
-    if not os.path.exists(result.out_dir):
-        os.makedirs(result.out_dir)
-
-    gen = generators[result.engine](result.out_dir)
-    gen.all_tags = [generateWord(3, 12) for _ in range(result.tag_count)]
-    gen.initialize()
-
-    for i in range(result.post_count):
-        gen.generatePost()
-
-
-if __name__ == '__main__':
-    main()
-
--- a/util/generate_changelog.py	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,161 +0,0 @@
-import os
-import os.path
-import re
-import sys
-import subprocess
-
-
-hg_log_template = ("{if(tags, '>>{tags};{date|shortdate}\n')}"
-                   "{desc|firstline}\n\n")
-
-re_add_tag_changeset = re.compile('^Added tag [^\s]+ for changeset [\w\d]+$')
-re_merge_pr_changeset = re.compile('^Merge pull request')
-re_tag = re.compile('^\d+\.\d+\.\d+([ab]\d+)?(rc\d+)?$')
-re_change = re.compile('^(\w+):')
-re_clean_code_span = re.compile('([^\s])``([^\s]+)')
-
-category_commands = [
-        'chef', 'bake', 'find', 'help', 'import', 'init', 'paths', 'plugin',
-        'plugins', 'prepare', 'purge', 'root', 'routes', 'serve',
-        'showconfig', 'showrecord', 'sources', 'theme', 'themes']
-category_core = [
-        'internal', 'bug', 'templating', 'formatting', 'performance',
-        'data', 'config', 'rendering', 'render', 'debug', 'reporting',
-        'linker', 'pagination', 'routing', 'caching']
-category_project = ['build', 'cm', 'docs', 'tests', 'setup']
-categories = [
-        ('commands', category_commands),
-        ('core', category_core),
-        ('project', category_project),
-        ('miscellaneous', None)]
-category_names = list(map(lambda i: i[0], categories))
-
-
-def generate():
-    out_file = 'CHANGELOG.rst'
-    if len(sys.argv) > 1:
-        out_file = sys.argv[1]
-
-    print("Generating %s" % out_file)
-
-    if not os.path.exists('.hg'):
-        raise Exception("You must run this script from the root of a "
-                        "Mercurial clone of the PieCrust repository.")
-    hglog = subprocess.check_output([
-        'hg', 'log',
-        '--rev', 'reverse(::master)',
-        '--template', hg_log_template])
-    hglog = hglog.decode('utf8')
-
-    templates = _get_templates()
-
-    with open(out_file, 'w') as fp:
-        fp.write(templates['header'])
-
-        skip = False
-        in_desc = False
-        current_version = 0
-        current_version_info = None
-        current_changes = None
-        for line in hglog.splitlines():
-            if line == '':
-                skip = False
-                in_desc = False
-                continue
-
-            if not in_desc and line.startswith('>>'):
-                tags, tag_date = line[2:].split(';')
-                if re_tag.match(tags):
-                    if current_version > 0:
-                        _write_version_changes(
-                                templates,
-                                current_version, current_version_info,
-                                current_changes, fp)
-
-                    current_version += 1
-                    current_version_info = tags, tag_date
-                    current_changes = {}
-                    in_desc = True
-                else:
-                    skip = True
-                continue
-
-            if skip or current_version == 0:
-                continue
-
-            if re_add_tag_changeset.match(line):
-                continue
-            if re_merge_pr_changeset.match(line):
-                continue
-
-            m = re_change.match(line)
-            if m:
-                ch_type = m.group(1)
-                for cat_name, ch_types in categories:
-                    if ch_types is None or ch_type in ch_types:
-                        msgs = current_changes.setdefault(cat_name, [])
-                        msgs.append(line)
-                        break
-                else:
-                    assert False, ("Change '%s' should have gone in the "
-                                   "misc. bucket." % line)
-            else:
-                msgs = current_changes.setdefault('miscellaneous', [])
-                msgs.append(line)
-
-        if current_version > 0:
-            _write_version_changes(
-                    templates,
-                    current_version, current_version_info,
-                    current_changes, fp)
-
-
-def _write_version_changes(templates, version, version_info, changes, fp):
-    tokens = {
-            'num': str(version),
-            'version': version_info[0],
-            'date': version_info[1]}
-    tpl = _multi_replace(templates['version_title'], tokens)
-    fp.write(tpl)
-
-    for i, cat_name in enumerate(category_names):
-        msgs = changes.get(cat_name)
-        if not msgs:
-            continue
-
-        tokens = {
-                'sub_num': str(i),
-                'category': cat_name.title()}
-        tpl = _multi_replace(templates['category_title'], tokens)
-        fp.write(tpl)
-
-        for msg in msgs:
-            msg = msg.replace('`', '``').rstrip('\n')
-            msg = re_clean_code_span.sub(r'\1`` \2', msg)
-            fp.write('* ' + msg + '\n')
-
-
-def _multi_replace(s, tokens):
-    for token in tokens:
-        s = s.replace('%%%s%%' % token, tokens[token])
-    return s
-
-
-def _get_templates():
-    tpl_dir = os.path.join(os.path.dirname(__file__), 'changelog')
-    tpls = {}
-    for name in os.listdir(tpl_dir):
-        tpl = _get_template(os.path.join(tpl_dir, name))
-        name_no_ext, _ = os.path.splitext(name)
-        tpls[name_no_ext] = tpl
-    return tpls
-
-
-def _get_template(filename):
-    with open(filename, 'r', encoding='utf8') as fp:
-        return fp.read()
-
-
-if __name__ == '__main__':
-    generate()
-
--- a/util/generate_messages.cmd	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-@echo off
-setlocal
-
-set CUR_DIR=%~dp0
-set CHEF=%CUR_DIR%..\bin\chef
-set OUT_DIR=%CUR_DIR%..\piecrust\resources\messages
-set ROOT_DIR=%CUR_DIR%messages
-
-%CHEF% --root=%ROOT_DIR% bake -o %OUT_DIR%
-del %OUT_DIR%\index.html
-
--- a/util/generate_messages.sh	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,9 +0,0 @@
-#!/bin/sh
-
-CUR_DIR="$( cd "$( dirname "$0" )" && pwd )"
-CHEF=${CUR_DIR}/../bin/chef
-OUT_DIR=${CUR_DIR}/../piecrust/resources/messages
-ROOT_DIR=${CUR_DIR}/messages
-
-$CHEF --root=$ROOT_DIR bake -o $OUT_DIR
-rm ${OUT_DIR}/index.html
--- a/util/messages/config.yml	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-site:
-    title: PieCrust System Messages
--- a/util/messages/pages/_index.html	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
----
-title: PieCrust System Messages
----
-
-Here are the **PieCrust** system message pages:
-
-* [Requirements Not Met]({{ pcurl('requirements') }})
-* [Error]({{ pcurl('error') }})
-* [Not Found]({{ pcurl('error404') }})
-* [Critical Error]({{ pcurl('critical') }})
-
-This very page you're reading, however, is only here for convenience.
--- a/util/messages/pages/critical.html	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
----
-title: The Whole Kitchen Burned Down!
-layout: error
----
-Something critically bad happened, and **PieCrust** needs to shut down. It's probably our fault.
-
--- a/util/messages/pages/error.html	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
----
-title: The Cake Just Burned!
-layout: error
----
-
--- a/util/messages/pages/error404.html	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
----
-title: Can't find the sugar!
-layout: error
----
-It looks like the page you were trying to access does not exist around here. Try going somewhere else.
-
--- a/util/messages/templates/default.html	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,70 +0,0 @@
-<!doctype html>
-<html>
-<head>
-    <title>{{ page.title }}</title>
-    <meta name="generator" content="PieCrust" />
-	<link rel="stylesheet" type="text/css" href="http://fonts.googleapis.com/css?family=Lobster">
-	<style>
-		body {
-			margin: 0;
-			padding: 1em;
-			background: #eee;
-			color: #000;
-			font-family: Georgia, serif;
-		}
-		h1 {
-			font-size: 4.5em;
-			font-family: Lobster, 'Trebuchet MS', Verdana, sans-serif;
-			text-align: center;
-			font-weight: bold;
-            margin-top: 0;
-			color: #333;
-			text-shadow: 0px 2px 5px rgba(0,0,0,0.3);
-		}
-		h2 {
-			font-size: 2.5em;
-            font-family: 'Lobster', 'Trebuchet MS', Verdana, sans-serif;
-		}
-		code {
-			background: #ddd;
-			padding: 0 0.2em;
-		}
-        #preamble {
-            font-size: 1.2em;
-            font-style: italic;
-            text-align: center;
-            margin-bottom: 0;
-        }
-		#container {
-			margin: 0 20%;
-		}
-        #content {
-            margin: 2em 1em;
-        }
-        .error-details {
-            color: #d11;
-        }
-		.note {
-			margin: 3em;
-			color: #888;
-			font-style: italic;
-		}
-	</style>
-</head>
-<body>
-    <div id="container">
-        <div id="header">
-            <p id="preamble">A Message From The Kitchen:</p>
-			<h1>{{ page.title }}</h1>
-		</div>
-        <hr />
-        <div id="content">
-            {% block content %}
-			{{ content|safe }}
-            {% endblock %}
-		</div>
-        <hr />
-        {% block footer %}{% endblock %}
-	</div>
-</body>
-</html>
--- a/util/messages/templates/error.html	Wed Dec 30 20:21:41 2015 +1300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-{% extends "default.html" %}
-
-{% block content %}
-{{content|safe}}
-
-{# The following is `raw` because we want it to be in the
-   produced page, so it can then be templated on the fly 
-   with the error messages #}
-{% raw %}
-{% if details %}
-<div class="error-details">
-    <p>Error details:</p>
-    <ul>
-    {% for desc in details %}
-        <li>{{ desc }}</li>
-    {% endfor %}
-    </ul>
-</div>
-{% endif %}
-{% endraw %}
-{% endblock %}
-
-{% block footer %}
-{% pcformat 'textile' %}
-p(note). You're seeing this because something wrong happend. To see detailed errors with callstacks, run chef with the @--debug@ parameter, append @?!debug@ to the URL, or initialize the @PieCrust@ object with @{'debug': true}@. On the other hand, to see you custom error pages, set the @site/display_errors@ setting  to @false@.
-{% endpcformat %}
-{% endblock %}