changeset 974:72f17534d58e

tests: First pass on making unit tests work again. - Fix all imports - Add more helper functions to work with mock file-systems - Simplify some code by running chef directly on the mock FS - Fix a couple tests
author Ludovic Chabant <ludovic@chabant.com>
date Tue, 17 Oct 2017 01:07:30 -0700
parents 8419daaa7a0e
children a0a62d0da723
files tests/bakes/test_variant.yaml tests/basefs.py tests/conftest.py tests/mockutil.py tests/rdrutil.py tests/test_appconfig.py tests/test_baking_baker.py tests/test_data_assetor.py tests/test_data_iterators.py tests/test_data_linker.py tests/test_data_paginator.py tests/test_data_provider.py tests/test_dataproviders_pageiterator.py tests/test_page.py tests/test_pipelines_asset.py tests/test_pipelines_page.py tests/test_processing_base.py tests/test_processing_tree.py tests/test_routing.py tests/test_serving.py tests/test_serving_util.py tests/test_sources_autoconfig.py tests/test_sources_base.py tests/test_templating_jinjaengine.py tests/test_templating_pystacheengine.py tests/tmpfs.py
diffstat 26 files changed, 1099 insertions(+), 1311 deletions(-) [+]
line wrap: on
line diff
--- a/tests/bakes/test_variant.yaml	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/bakes/test_variant.yaml	Tue Oct 17 01:07:30 2017 -0700
@@ -1,7 +1,7 @@
 ---
 config:
     what: not good
-config_variant: test
+config_variants: [test]
 in:
     pages/_index.md: 'This is {{what}}.'
     configs/test.yml: 'what: awesome'
--- a/tests/basefs.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/basefs.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,6 +1,8 @@
 import os.path
 import yaml
 from piecrust.app import PieCrust
+from piecrust.main import _pre_parse_chef_args, _run_chef
+from piecrust.sources.base import ContentItem
 
 
 class TestFileSystemBase(object):
@@ -47,13 +49,13 @@
         if config is None:
             config = {}
         return self.withFile(
-                'kitchen/config.yml',
-                yaml.dump(config))
+            'kitchen/config.yml',
+            yaml.dump(config))
 
     def withThemeConfig(self, config):
         return self.withFile(
-                'kitchen/theme_config.yml',
-                yaml.dump(config))
+            'kitchen/theme_config.yml',
+            yaml.dump(config))
 
     def withPage(self, url, config=None, contents=None):
         config = config or {}
@@ -74,7 +76,7 @@
         url_base, ext = os.path.splitext(page_url)
         dirname = url_base + '-assets'
         return self.withAsset(
-                '%s/%s' % (dirname, name), contents)
+            '%s/%s' % (dirname, name), contents)
 
     def withPages(self, num, url_factory, config_factory=None,
                   contents_factory=None):
@@ -95,3 +97,20 @@
             self.withPage(url, config, contents)
         return self
 
+    def runChef(self, *args):
+        root_dir = self.path('/kitchen')
+        chef_args = ['--root', root_dir] + args
+
+        pre_args = _pre_parse_chef_args(chef_args)
+        exit_code = _run_chef(pre_args, chef_args)
+        assert exit_code == 0
+
+    def getSimplePage(self, rel_path):
+        app = self.getApp()
+        source = app.getSource('pages')
+        content_item = ContentItem(
+            os.path.join(source.fs_endpoint_path, rel_path),
+            {'route_params': {
+                'slug': os.path.splitext(rel_path)[0]}})
+        return app.getPage(source, content_item)
+
--- a/tests/conftest.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/conftest.py	Tue Oct 17 01:07:30 2017 -0700
@@ -8,7 +8,7 @@
 import yaml
 import colorama
 from werkzeug.exceptions import HTTPException
-from piecrust.app import apply_variant_and_values
+from piecrust.app import PieCrustFactory, apply_variants_and_values
 from piecrust.configuration import merge_dicts
 from .mockutil import mock_fs, mock_fs_scope
 
@@ -19,13 +19,16 @@
 
 def pytest_addoption(parser):
     parser.addoption(
-            '--log-debug',
-            action='store_true',
-            help="Sets the PieCrust logger to output debug info to stdout.")
+        '--log-debug',
+        action='store_true',
+        help="Sets the PieCrust logger to output debug info to stdout.")
     parser.addoption(
-            '--mock-debug',
-            action='store_true',
-            help="Prints contents of the mock file-system.")
+        '--log-file',
+        help="Sets the PieCrust logger to write to a file.")
+    parser.addoption(
+        '--mock-debug',
+        action='store_true',
+        help="Prints contents of the mock file-system.")
 
 
 def pytest_configure(config):
@@ -34,6 +37,12 @@
         logging.getLogger('piecrust').addHandler(hdl)
         logging.getLogger('piecrust').setLevel(logging.DEBUG)
 
+    log_file = config.getoption('--log-file')
+    if log_file:
+        hdl = logging.StreamHandler(
+            stream=open(log_file, 'w', encoding='utf8'))
+        logging.getLogger().addHandler(hdl)
+
 
 def pytest_collect_file(parent, path):
     if path.ext == '.yaml' and path.basename.startswith("test"):
@@ -55,8 +64,8 @@
         import traceback
         ex = excinfo.value
         return '\n'.join(
-                traceback.format_exception(
-                    type(ex), ex, ex.__traceback__))
+            traceback.format_exception(
+                type(ex), ex, ex.__traceback__))
     return ''
 
 
@@ -89,11 +98,11 @@
         # Suppress any formatting or layout so we can compare
         # much simpler strings.
         config = {
-                'site': {
-                    'default_format': 'none',
-                    'default_page_layout': 'none',
-                    'default_post_layout': 'none'}
-                }
+            'site': {
+                'default_format': 'none',
+                'default_page_layout': 'none',
+                'default_post_layout': 'none'}
+        }
 
         # Website or theme config.
         test_theme_config = self.spec.get('theme_config')
@@ -251,21 +260,25 @@
             out_dir = fs.path('kitchen/_counter')
             app = fs.getApp(theme_site=self.is_theme_site)
 
-            variant = self.spec.get('config_variant')
             values = self.spec.get('config_values')
             if values is not None:
                 values = list(values.items())
-            apply_variant_and_values(app, variant, values)
+            variants = self.spec.get('config_variants')
+            if variants is not None:
+                variants = list(variants.items())
+            apply_variants_and_values(app, variants, values)
 
-            baker = Baker(app, out_dir,
-                          applied_config_variant=variant,
-                          applied_config_values=values)
-            record = baker.bake()
+            appfactory = PieCrustFactory(app.root_dir,
+                                         config_variants=variants,
+                                         config_values=values)
+            baker = Baker(appfactory, app, out_dir)
+            records = baker.bake()
 
-            if not record.success:
+            if not records.success:
                 errors = []
-                for e in record.entries:
-                    errors += e.getAllErrors()
+                for r in records.records:
+                    for e in r.getEntries():
+                        errors += e.getAllErrors()
                 raise BakeError(errors)
 
             check_expected_outputs(self.spec, fs, ExpectedBakeOutputError)
@@ -382,16 +395,16 @@
             if is_admin_test:
                 from piecrust.admin.web import create_foodtruck_app
                 s = {
-                        'FOODTRUCK_CMDLINE_MODE': True,
-                        'FOODTRUCK_ROOT': fs.path('/kitchen')
-                        }
+                    'FOODTRUCK_CMDLINE_MODE': True,
+                    'FOODTRUCK_ROOT': fs.path('/kitchen')
+                }
                 test_app = create_foodtruck_app(s)
             else:
                 from piecrust.app import PieCrustFactory
                 from piecrust.serving.server import Server
                 appfactory = PieCrustFactory(
-                        fs.path('/kitchen'),
-                        theme_site=self.is_theme_site)
+                    fs.path('/kitchen'),
+                    theme_site=self.is_theme_site)
                 server = Server(appfactory)
                 test_app = self._TestApp(server)
 
@@ -417,15 +430,15 @@
         from piecrust.serving.server import MultipleNotFound
         if isinstance(excinfo.value, MultipleNotFound):
             res = '\n'.join(
-                    ["HTTP error 404 returned:",
-                     str(excinfo.value)] +
-                    [str(e) for e in excinfo.value._nfes])
+                ["HTTP error 404 returned:",
+                 str(excinfo.value)] +
+                [str(e) for e in excinfo.value._nfes])
             res += repr_nested_failure(excinfo)
             return res
         elif isinstance(excinfo.value, HTTPException):
             res = '\n'.join(
-                    ["HTTP error %s returned:" % excinfo.value.code,
-                     excinfo.value.description])
+                ["HTTP error %s returned:" % excinfo.value.code,
+                 excinfo.value.description])
             res += repr_nested_failure(excinfo)
             return res
         return super(ServeTestItem, self).repr_failure(excinfo)
@@ -451,8 +464,8 @@
 
     def createChildContext(self, name):
         ctx = CompareContext(
-                path='%s/%s' % (self.path, name),
-                t=self.time)
+            path='%s/%s' % (self.path, name),
+            t=self.time)
         return ctx
 
 
--- a/tests/mockutil.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/mockutil.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,8 +1,6 @@
-import os.path
 import mock
-from piecrust.app import PieCrust, PieCrustConfiguration
-from piecrust.page import Page
-from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
+from piecrust.app import PieCrust
+from piecrust.appconfig import PieCrustConfiguration
 
 
 def get_mock_app(config=None):
@@ -11,20 +9,21 @@
     return app
 
 
-def get_simple_page(app, rel_path):
-    source = app.getSource('pages')
-    metadata = {'slug': os.path.splitext(rel_path)[0]}
-    return Page(source, metadata, rel_path)
+def get_simple_content_item(app, slug):
+    src = app.getSource('pages')
+    assert src is not None
+
+    item = src.findContent({'slug': slug})
+    assert item is not None
+    return item
 
 
-def render_simple_page(page, route, route_metadata):
-    qp = QualifiedPage(page, route, route_metadata)
-    ctx = PageRenderingContext(qp)
-    rp = render_page(ctx)
-    return rp.content
+def get_simple_page(app, slug):
+    src = app.getSource('pages')
+    item = get_simple_content_item(app, slug)
+    return app.getPage(src, item)
 
 
-from .tmpfs import (
-        TempDirFileSystem as mock_fs,
-        TempDirScope as mock_fs_scope)
-
+from .tmpfs import (  # NOQA
+    TempDirFileSystem as mock_fs,
+    TempDirScope as mock_fs_scope)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/rdrutil.py	Tue Oct 17 01:07:30 2017 -0700
@@ -0,0 +1,8 @@
+from piecrust.rendering import RenderingContext, render_page
+
+
+def render_simple_page(page):
+    ctx = RenderingContext(page)
+    rp = render_page(ctx)
+    return rp.content
+
--- a/tests/test_appconfig.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_appconfig.py	Tue Oct 17 01:07:30 2017 -0700
@@ -7,7 +7,9 @@
     values = {}
     config = PieCrustConfiguration(values=values)
     assert config.get('site/root') == '/'
-    assert len(config.get('site/sources')) == 3  # pages, posts, theme_pages
+    assert len(config.get('site/sources').keys()) == \
+        len(['theme_assets', 'assets', 'theme_pages', 'pages', 'posts',
+             'tags', 'categories', 'archives'])
 
 
 def test_config_site_override_title():
@@ -29,11 +31,11 @@
         assert app.config.get('site/default_post_layout') == 'bar'
         assert app.config.get('site/sources/pages/default_layout') == 'foo'
         assert app.config.get('site/sources/pages/items_per_page') == 5
+        assert app.config.get('site/sources/posts/default_layout') == 'bar'
+        assert app.config.get('site/sources/posts/items_per_page') == 2
         assert app.config.get(
             'site/sources/theme_pages/default_layout') == 'default'
         assert app.config.get('site/sources/theme_pages/items_per_page') == 5
-        assert app.config.get('site/sources/posts/default_layout') == 'bar'
-        assert app.config.get('site/sources/posts/items_per_page') == 2
 
 
 def test_config_site_add_source():
@@ -53,13 +55,16 @@
                     'notes', 'posts', 'posts_archives', 'posts_tags',
                     'posts_categories', 'pages', 'theme_pages'])
         assert set(app.config.get('site/sources').keys()) == set([
-            'theme_pages', 'pages', 'posts', 'notes'])
+            'theme_pages', 'theme_assets', 'pages', 'posts', 'assets',
+            'posts_tags', 'posts_categories', 'posts_archives',
+            'notes'])
 
 
 def test_config_site_add_source_in_both_site_and_theme():
     theme_config = {'site': {
         'sources': {'theme_notes': {}},
-        'routes': [{'url': '/theme_notes/%path:slug%', 'source': 'theme_notes'}]
+        'routes': [{'url': '/theme_notes/%path:slug%',
+                    'source': 'theme_notes'}]
     }}
     config = {'site': {
         'sources': {'notes': {}},
@@ -81,7 +86,9 @@
                     'posts_categories', 'pages', 'theme_notes',
                     'theme_pages'])
         assert set(app.config.get('site/sources').keys()) == set([
-            'theme_pages', 'theme_notes', 'pages', 'posts', 'notes'])
+            'theme_pages', 'theme_assets', 'theme_notes',
+            'pages', 'posts', 'assets', 'posts_tags', 'posts_categories',
+            'posts_archives', 'notes'])
 
 
 def test_multiple_blogs():
@@ -99,7 +106,10 @@
                     'bbb', 'bbb_archives', 'bbb_tags', 'bbb_categories',
                     'pages', 'theme_pages'])
         assert set(app.config.get('site/sources').keys()) == set([
-            'aaa', 'bbb', 'pages', 'theme_pages'])
+            'aaa', 'aaa_tags', 'aaa_categories', 'aaa_archives',
+            'bbb', 'bbb_tags', 'bbb_categories', 'bbb_archives',
+            'pages', 'assets',
+            'theme_pages', 'theme_assets'])
 
 
 def test_custom_list_setting():
--- a/tests/test_baking_baker.py	Tue Oct 17 01:04:10 2017 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,108 +0,0 @@
-import time
-import os.path
-import urllib.parse
-import pytest
-from piecrust.baking.baker import Baker
-from piecrust.baking.single import PageBaker
-from piecrust.baking.records import BakeRecord
-from .mockutil import get_mock_app, mock_fs, mock_fs_scope
-
-
-@pytest.mark.parametrize('uri, pretty, expected', [
-        # Pretty URLs
-        ('', True, 'index.html'),
-        ('2', True, '2/index.html'),
-        ('foo', True, 'foo/index.html'),
-        ('foo/2', True, 'foo/2/index.html'),
-        ('foo/bar', True, 'foo/bar/index.html'),
-        ('foo/bar/2', True, 'foo/bar/2/index.html'),
-        ('foo.ext', True, 'foo.ext/index.html'),
-        ('foo.ext/2', True, 'foo.ext/2/index.html'),
-        ('foo/bar.ext', True, 'foo/bar.ext/index.html'),
-        ('foo/bar.ext/2', True, 'foo/bar.ext/2/index.html'),
-        ('foo.bar.ext', True, 'foo.bar.ext/index.html'),
-        ('foo.bar.ext/2', True, 'foo.bar.ext/2/index.html'),
-        # Ugly URLs
-        ('', False, 'index.html'),
-        ('2.html', False, '2.html'),
-        ('foo.html', False, 'foo.html'),
-        ('foo/2.html', False, 'foo/2.html'),
-        ('foo/bar.html', False, 'foo/bar.html'),
-        ('foo/bar/2.html', False, 'foo/bar/2.html'),
-        ('foo.ext', False, 'foo.ext'),
-        ('foo/2.ext', False, 'foo/2.ext'),
-        ('foo/bar.ext', False, 'foo/bar.ext'),
-        ('foo/bar/2.ext', False, 'foo/bar/2.ext'),
-        ('foo.bar.ext', False, 'foo.bar.ext'),
-        ('foo.bar/2.ext', False, 'foo.bar/2.ext')
-        ])
-def test_get_output_path(uri, pretty, expected):
-    app = get_mock_app()
-    if pretty:
-        app.config.set('site/pretty_urls', True)
-    assert app.config.get('site/pretty_urls') == pretty
-
-    for site_root in ['/', '/whatever/', '/~johndoe/']:
-        app.config.set('site/root', urllib.parse.quote(site_root))
-        baker = PageBaker(app, '/destination')
-        try:
-            path = baker.getOutputPath(urllib.parse.quote(site_root) + uri,
-                                       pretty)
-            expected = os.path.normpath(
-                    os.path.join('/destination', expected))
-            assert expected == path
-        finally:
-            baker.shutdown()
-
-
-def test_removed():
-    fs = (mock_fs()
-            .withConfig()
-            .withPage('pages/foo.md', {'layout': 'none', 'format': 'none'}, 'a foo page')
-            .withPage('pages/_index.md', {'layout': 'none', 'format': 'none'}, "something"))
-    with mock_fs_scope(fs):
-        out_dir = fs.path('kitchen/_counter')
-        app = fs.getApp()
-        app.config.set('baker/workers', 1)
-        baker = Baker(app, out_dir)
-        baker.bake()
-        structure = fs.getStructure('kitchen/_counter')
-        assert structure == {
-                'foo.html': 'a foo page',
-                'index.html': 'something'}
-
-        os.remove(fs.path('kitchen/pages/foo.md'))
-        app = fs.getApp()
-        baker = Baker(app, out_dir)
-        baker.bake()
-        structure = fs.getStructure('kitchen/_counter')
-        assert structure == {
-                'index.html': 'something'}
-
-
-def test_record_version_change():
-    fs = (mock_fs()
-            .withConfig()
-            .withPage('pages/foo.md', {'layout': 'none', 'format': 'none'}, 'a foo page'))
-    with mock_fs_scope(fs):
-        out_dir = fs.path('kitchen/_counter')
-        app = fs.getApp()
-        baker = Baker(app, out_dir)
-        baker.bake()
-        mtime = os.path.getmtime(fs.path('kitchen/_counter/foo.html'))
-        time.sleep(1)
-
-        app = fs.getApp()
-        baker = Baker(app, out_dir)
-        baker.bake()
-        assert mtime == os.path.getmtime(fs.path('kitchen/_counter/foo.html'))
-
-        BakeRecord.RECORD_VERSION += 1
-        try:
-            app = fs.getApp()
-            baker = Baker(app, out_dir)
-            baker.bake()
-            assert mtime < os.path.getmtime(fs.path('kitchen/_counter/foo.html'))
-        finally:
-            BakeRecord.RECORD_VERSION -= 1
-
--- a/tests/test_data_assetor.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_data_assetor.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,48 +1,47 @@
 import pytest
-from mock import MagicMock
-from piecrust.data.assetor import (
-        Assetor, UnsupportedAssetsError, build_base_url)
-from .mockutil import mock_fs, mock_fs_scope
+from piecrust.data.assetor import Assetor, UnsupportedAssetsError
+from .mockutil import mock_fs, mock_fs_scope, get_simple_page
 
 
 @pytest.mark.parametrize('fs_fac, site_root, expected', [
-        (lambda: mock_fs().withPage('pages/foo/bar'), '/', {}),
-        (lambda: mock_fs()
-            .withPage('pages/foo/bar')
-            .withPageAsset('pages/foo/bar', 'one.txt', 'one'),
-            '/',
-            {'one': 'one'}),
-        (lambda: mock_fs()
-            .withPage('pages/foo/bar')
-            .withPageAsset('pages/foo/bar', 'one.txt', 'one')
-            .withPageAsset('pages/foo/bar', 'two.txt', 'two'),
-            '/',
-            {'one': 'one', 'two': 'two'}),
+    (lambda: mock_fs().withPage('pages/foo/bar'), '/', {}),
+    (lambda: mock_fs()
+     .withPage('pages/foo/bar')
+     .withPageAsset('pages/foo/bar', 'one.txt', 'one'),
+     '/',
+     {'one': 'one'}),
+    (lambda: mock_fs()
+     .withPage('pages/foo/bar')
+     .withPageAsset('pages/foo/bar', 'one.txt', 'one')
+     .withPageAsset('pages/foo/bar', 'two.txt', 'two'),
+     '/',
+     {'one': 'one', 'two': 'two'}),
 
-        (lambda: mock_fs().withPage('pages/foo/bar'), '/whatever', {}),
-        (lambda: mock_fs()
-            .withPage('pages/foo/bar')
-            .withPageAsset('pages/foo/bar', 'one.txt', 'one'),
-            '/whatever',
-            {'one': 'one'}),
-        (lambda: mock_fs()
-            .withPage('pages/foo/bar')
-            .withPageAsset('pages/foo/bar', 'one.txt', 'one')
-            .withPageAsset('pages/foo/bar', 'two.txt', 'two'),
-            '/whatever',
-            {'one': 'one', 'two': 'two'})
-        ])
+    (lambda: mock_fs().withPage('pages/foo/bar'), '/whatever', {}),
+    (lambda: mock_fs()
+     .withPage('pages/foo/bar')
+     .withPageAsset('pages/foo/bar', 'one.txt', 'one'),
+     '/whatever',
+     {'one': 'one'}),
+    (lambda: mock_fs()
+     .withPage('pages/foo/bar')
+     .withPageAsset('pages/foo/bar', 'one.txt', 'one')
+     .withPageAsset('pages/foo/bar', 'two.txt', 'two'),
+     '/whatever',
+     {'one': 'one', 'two': 'two'})
+])
 def test_assets(fs_fac, site_root, expected):
     fs = fs_fac()
     fs.withConfig({'site': {'root': site_root}})
     with mock_fs_scope(fs):
-        page = MagicMock()
-        page.app = fs.getApp(cache=False)
-        page.app.env.base_asset_url_format = '%uri%'
-        page.path = fs.path('/kitchen/pages/foo/bar.md')
-        assetor = Assetor(page, site_root.rstrip('/') + '/foo/bar')
+        app = fs.getApp()
+        app.config.set('site/asset_url_format', '%page_uri%/%filename%')
+        page = get_simple_page(app, 'foo/bar')
+
+        assetor = Assetor(page)
         for en in expected.keys():
             assert hasattr(assetor, en)
+            assert en in assetor
             path = site_root.rstrip('/') + '/foo/bar/%s.txt' % en
             assert getattr(assetor, en) == path
             assert assetor[en] == path
@@ -51,45 +50,28 @@
 def test_missing_asset():
     with pytest.raises(KeyError):
         fs = (mock_fs()
-                .withConfig()
-                .withPage('pages/foo/bar'))
+              .withConfig()
+              .withPage('pages/foo/bar'))
         with mock_fs_scope(fs):
-            page = MagicMock()
-            page.app = fs.getApp(cache=False)
-            page.path = fs.path('/kitchen/pages/foo/bar.md')
-            assetor = Assetor(page, '/foo/bar')
+            app = fs.getApp()
+            app.config.set('site/asset_url_format', '%page_uri%/%filename%')
+            page = get_simple_page(app, 'foo/bar')
+
+            assetor = Assetor(page)
             assetor['this_doesnt_exist']
 
 
 def test_multiple_assets_with_same_name():
     with pytest.raises(UnsupportedAssetsError):
         fs = (mock_fs()
-                .withConfig()
-                .withPage('pages/foo/bar')
-                .withPageAsset('pages/foo/bar', 'one.txt', 'one text')
-                .withPageAsset('pages/foo/bar', 'one.jpg', 'one picture'))
+              .withConfig()
+              .withPage('pages/foo/bar')
+              .withPageAsset('pages/foo/bar', 'one.txt', 'one text')
+              .withPageAsset('pages/foo/bar', 'one.jpg', 'one picture'))
         with mock_fs_scope(fs):
-            page = MagicMock()
-            page.app = fs.getApp(cache=False)
-            page.path = fs.path('/kitchen/pages/foo/bar.md')
-            assetor = Assetor(page, '/foo/bar')
-            assetor['one']
-
+            app = fs.getApp()
+            app.config.set('site/asset_url_format', '%page_uri%/%filename%')
+            page = get_simple_page(app, 'foo/bar')
 
-@pytest.mark.parametrize('url_format, pretty_urls, uri, expected', [
-        ('%uri%', True, '/foo', '/foo/'),
-        ('%uri%', True, '/foo.ext', '/foo.ext/'),
-        ('%uri%', False, '/foo.html', '/foo/'),
-        ('%uri%', False, '/foo.ext', '/foo/'),
-        ])
-def test_build_base_url(url_format, pretty_urls, uri, expected):
-    app = MagicMock()
-    app.env = MagicMock()
-    app.env.base_asset_url_format = url_format
-    app.config = {
-            'site/root': '/',
-            'site/pretty_urls': pretty_urls}
-    assets_path = 'foo/bar-assets'
-    actual = build_base_url(app, uri, assets_path)
-    assert actual == expected
-
+            assetor = Assetor(page)
+            assetor['one']
--- a/tests/test_data_iterators.py	Tue Oct 17 01:04:10 2017 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-import mock
-from piecrust.data.iterators import PageIterator
-from piecrust.page import Page, PageConfiguration
-
-
-def test_skip():
-    it = PageIterator(range(12))
-    it.skip(5)
-    assert it.total_count == 12
-    assert len(it) == 7
-    assert list(it) == list(range(5, 12))
-
-
-def test_limit():
-    it = PageIterator(range(12))
-    it.limit(4)
-    assert it.total_count == 12
-    assert len(it) == 4
-    assert list(it) == list(range(4))
-
-
-def test_slice():
-    it = PageIterator(range(12))
-    it.slice(3, 4)
-    assert it.total_count == 12
-    assert len(it) == 4
-    assert list(it) == list(range(3, 7))
-
-
-def test_natural_sort():
-    it = PageIterator([4, 3, 1, 2, 0])
-    it.sort()
-    assert it.total_count == 5
-    assert len(it) == 5
-    assert list(it) == list(range(5))
-
-
-def test_natural_sort_reversed():
-    it = PageIterator([4, 3, 1, 2, 0])
-    it.sort(reverse=True)
-    assert it.total_count == 5
-    assert len(it) == 5
-    assert list(it) == list(reversed(range(5)))
-
-
-class TestItem(object):
-    def __init__(self, value):
-        self.name = str(value)
-        self.foo = value
-
-    def __eq__(self, other):
-        return other.name == self.name
-
-
-def test_setting_sort():
-    it = PageIterator([TestItem(v) for v in [4, 3, 1, 2, 0]])
-    it.sort('foo')
-    assert it.total_count == 5
-    assert len(it) == 5
-    assert list(it) == [TestItem(v) for v in range(5)]
-
-
-def test_setting_sort_reversed():
-    it = PageIterator([TestItem(v) for v in [4, 3, 1, 2, 0]])
-    it.sort('foo', reverse=True)
-    assert it.total_count == 5
-    assert len(it) == 5
-    assert list(it) == [TestItem(v) for v in reversed(range(5))]
-
-
-def test_filter():
-    page = mock.MagicMock(spec=Page)
-    page.config = PageConfiguration()
-    page.config.set('threes', {'is_foo': 3})
-    it = PageIterator([TestItem(v) for v in [3, 2, 3, 1, 4, 3]],
-                      current_page=page)
-    it.filter('threes')
-    assert it.total_count == 3
-    assert len(it) == 3
-    assert list(it) == [TestItem(3), TestItem(3), TestItem(3)]
-
-
-def test_magic_filter():
-    it = PageIterator([TestItem(v) for v in [3, 2, 3, 1, 4, 3]])
-    it.is_foo(3)
-    assert it.total_count == 3
-    assert len(it) == 3
-    assert list(it) == [TestItem(3), TestItem(3), TestItem(3)]
-
--- a/tests/test_data_linker.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_data_linker.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,101 +1,73 @@
-import os.path
 import pytest
 from piecrust.data.linker import Linker
-from .mockutil import mock_fs, mock_fs_scope
+from .mockutil import mock_fs, mock_fs_scope, get_simple_content_item
 
 
 @pytest.mark.parametrize(
     'fs_fac, page_path, expected',
     [
-        (lambda: mock_fs().withPage('pages/foo'), 'foo.md',
-            # is_dir, name, is_self, data
-            [(False, 'foo', True, '/foo')]),
+        (lambda: mock_fs().withPage('pages/foo'), 'foo',
+         []),
         ((lambda: mock_fs()
-                .withPage('pages/foo')
-                .withPage('pages/bar')),
-            'foo.md',
-            [(False, 'bar', False, '/bar'), (False, 'foo', True, '/foo')]),
+          .withPage('pages/foo')
+          .withPage('pages/bar')),
+         'foo',
+         ['/bar']),
         ((lambda: mock_fs()
-                .withPage('pages/baz')
-                .withPage('pages/something')
-                .withPage('pages/something/else')
-                .withPage('pages/foo')
-                .withPage('pages/bar')),
-            'foo.md',
-            [(False, 'bar', False, '/bar'),
-                (False, 'baz', False, '/baz'),
-                (False, 'foo', True, '/foo'),
-                (True, 'something', False, '/something')]),
+          .withPage('pages/baz')
+          .withPage('pages/something')
+          .withPage('pages/something/else')
+          .withPage('pages/foo')
+          .withPage('pages/bar')),
+         'foo',
+         ['/bar', '/baz', '/something']),
         ((lambda: mock_fs()
-                .withPage('pages/something/else')
-                .withPage('pages/foo')
-                .withPage('pages/something/good')
-                .withPage('pages/bar')),
-            'something/else.md',
-            [(False, 'else', True, '/something/else'),
-                (False, 'good', False, '/something/good')])
+          .withPage('pages/something/else')
+          .withPage('pages/foo')
+          .withPage('pages/something/good')
+          .withPage('pages/bar')),
+         'something/else',
+         ['/something/good'])
     ])
-def test_linker_iteration(fs_fac, page_path, expected):
+def test_linker_siblings(fs_fac, page_path, expected):
     fs = fs_fac()
     fs.withConfig()
     with mock_fs_scope(fs):
         app = fs.getApp()
         app.config.set('site/pretty_urls', True)
         src = app.getSource('pages')
-        linker = Linker(src, os.path.dirname(page_path),
-                        root_page_path=page_path)
-        actual = list(iter(linker))
-
-        assert len(actual) == len(expected)
-        for (a, e) in zip(actual, expected):
-            is_dir, name, is_self, url = e
-            assert a.is_dir == is_dir
-            assert a.name == name
-            assert a.is_self == is_self
-            assert a.url == url
+        item = get_simple_content_item(app, page_path)
+        linker = Linker(src, item)
+        actual = list(linker.siblings)
+        assert sorted(map(lambda i: i.url, actual)) == sorted(expected)
 
 
 @pytest.mark.parametrize(
-        'fs_fac, page_path, expected',
-        [
-            (lambda: mock_fs().withPage('pages/foo'), 'foo.md',
-                [('/foo', True)]),
-            ((lambda: mock_fs()
-                    .withPage('pages/foo')
-                    .withPage('pages/bar')),
-                'foo.md',
-                [('/bar', False), ('/foo', True)]),
-            ((lambda: mock_fs()
-                    .withPage('pages/baz')
-                    .withPage('pages/something/else')
-                    .withPage('pages/foo')
-                    .withPage('pages/bar')),
-                'foo.md',
-                [('/bar', False), ('/baz', False),
-                    ('/foo', True), ('/something/else', False)]),
-            ((lambda: mock_fs()
-                    .withPage('pages/something/else')
-                    .withPage('pages/foo')
-                    .withPage('pages/something/good')
-                    .withPage('pages/bar')),
-                'something/else.md',
-                [('/something/else', True),
-                    ('/something/good', False)])
-        ])
-def test_recursive_linker_iteration(fs_fac, page_path, expected):
+    'fs_fac, page_path, expected',
+    [
+        (lambda: mock_fs().withPage('pages/foo'), 'foo.md',
+         []),
+        ((lambda: mock_fs()
+          .withPage('pages/foo')
+          .withPage('pages/bar')),
+         'foo',
+         []),
+        ((lambda: mock_fs()
+          .withPage('pages/baz')
+          .withPage('pages/foo')
+          .withPage('pages/foo/more')
+          .withPage('pages/foo/even_more')),
+         'foo',
+         ['/foo/more', '/foo/even_more'])
+    ])
+def test_linker_children(fs_fac, page_path, expected):
     fs = fs_fac()
     fs.withConfig()
     with mock_fs_scope(fs):
         app = fs.getApp()
         app.config.set('site/pretty_urls', True)
         src = app.getSource('pages')
-        linker = Linker(src, os.path.dirname(page_path),
-                        root_page_path=page_path)
-        actual = list(iter(linker.allpages))
-
-        assert len(actual) == len(expected)
-        for i, (a, e) in enumerate(zip(actual, expected)):
-            assert a.is_dir is False
-            assert a.url == e[0]
-            assert a.is_self == e[1]
-
+        item = get_simple_content_item(app, page_path)
+        linker = Linker(src, item)
+        actual = list(linker.children)
+        assert sorted(map(lambda i: i.url, actual)) == sorted(expected)
--- a/tests/test_data_paginator.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_data_paginator.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,49 +1,32 @@
 import math
-import mock
 import pytest
 from piecrust.data.paginator import Paginator
-from piecrust.sources.interfaces import IPaginationSource
 
 
-class MockSource(list, IPaginationSource):
+class MockSource(list):
     def __init__(self, count):
         for i in range(count):
             self.append('item %d' % i)
 
-    def getItemsPerPage(self):
-        return 5
-
-    def getSourceIterator(self):
-        return None
-
-    def getSorterIterator(self, it):
-        return None
-
-    def getTailIterator(self, it):
-        return None
-
-    def getPaginationFilter(self, page):
-        return None
-
 
 @pytest.mark.parametrize('uri, page_num, count', [
-        ('', 1, 0),
-        ('', 1, 4),
-        ('', 1, 5),
-        ('', 1, 8),
-        ('', 1, 14),
-        ('', 2, 8),
-        ('', 2, 14),
-        ('', 3, 14),
-        ('blog', 1, 0),
-        ('blog', 1, 4),
-        ('blog', 1, 5),
-        ('blog', 1, 8),
-        ('blog', 1, 14),
-        ('blog', 2, 8),
-        ('blog', 2, 14),
-        ('blog', 3, 14)
-    ])
+    ('', 1, 0),
+    ('', 1, 4),
+    ('', 1, 5),
+    ('', 1, 8),
+    ('', 1, 14),
+    ('', 2, 8),
+    ('', 2, 14),
+    ('', 3, 14),
+    ('blog', 1, 0),
+    ('blog', 1, 4),
+    ('blog', 1, 5),
+    ('blog', 1, 8),
+    ('blog', 1, 14),
+    ('blog', 2, 8),
+    ('blog', 2, 14),
+    ('blog', 3, 14)
+])
 def test_paginator(uri, page_num, count):
     def _get_mock_uri(sub_num):
         res = uri
@@ -54,7 +37,8 @@
         return res
 
     source = MockSource(count)
-    p = Paginator(None, source, page_num=page_num)
+    p = Paginator(source, None, page_num)
+    p._items_per_page = 5
     p._getPageUri = _get_mock_uri
 
     if count <= 5:
@@ -81,12 +65,12 @@
             assert p.prev_page == uri
         else:
             pp = str(page_num - 1) if uri == '' else (
-                    '%s/%d' % (uri, page_num - 1))
+                '%s/%d' % (uri, page_num - 1))
             assert p.prev_page == pp
 
         assert p.this_page_number == page_num
         tp = str(page_num) if uri == '' else (
-                '%s/%d' % (uri, page_num))
+            '%s/%d' % (uri, page_num))
         assert p.this_page == tp
 
         if page_num * 5 > count:
@@ -95,7 +79,7 @@
         else:
             assert p.next_page_number == page_num + 1
             np = str(page_num + 1) if uri == '' else (
-                    '%s/%d' % (uri, page_num + 1))
+                '%s/%d' % (uri, page_num + 1))
             assert p.next_page == np
 
     assert p.total_post_count == count
@@ -118,7 +102,8 @@
                     nums = list(range(1, to_add + 1)) + nums
                 else:
                     to_add = min(to_add, page_count - nums[-1])
-                    nums = nums + list(range(nums[-1] + 1, nums[-1] + to_add + 1))
+                    nums = nums + list(range(nums[-1] + 1,
+                                             nums[-1] + to_add + 1))
         assert nums == p.all_page_numbers(radius)
 
     itp = count
@@ -130,7 +115,7 @@
     assert p.items_this_page == itp
 
     indices = list(range(count))
-    indices = indices[(page_num - 1) * 5 : (page_num - 1) * 5 + itp]
+    indices = indices[(page_num - 1) * 5:(page_num - 1) * 5 + itp]
     expected = list(['item %d' % i for i in indices])
     items = list(p.items)
     assert items == expected
--- a/tests/test_data_provider.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_data_provider.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,5 +1,5 @@
-from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
 from .mockutil import mock_fs, mock_fs_scope
+from .rdrutil import render_simple_page
 
 
 def test_blog_provider():
@@ -18,12 +18,8 @@
                     "{%endfor%}\n"))
     with mock_fs_scope(fs):
         app = fs.getApp()
-        page = app.getSource('pages').getPage({'slug': 'tags'})
-        route = app.getSourceRoute('pages', None)
-        route_metadata = {'slug': 'tags'}
-        qp = QualifiedPage(page, route, route_metadata)
-        ctx = PageRenderingContext(qp)
-        rp = render_page(ctx)
+        page = app.getSimplePage('tags.md')
+        actual = render_simple_page(page)
         expected = "\nBar (1)\n\nFoo (2)\n"
-        assert rp.content == expected
+        assert actual == expected
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_dataproviders_pageiterator.py	Tue Oct 17 01:07:30 2017 -0700
@@ -0,0 +1,89 @@
+import mock
+from piecrust.dataproviders.pageiterator import PageIterator
+from piecrust.page import Page, PageConfiguration
+
+
+def test_skip():
+    it = PageIterator(range(12))
+    it.skip(5)
+    assert it.total_count == 12
+    assert len(it) == 7
+    assert list(it) == list(range(5, 12))
+
+
+def test_limit():
+    it = PageIterator(range(12))
+    it.limit(4)
+    assert it.total_count == 12
+    assert len(it) == 4
+    assert list(it) == list(range(4))
+
+
+def test_slice():
+    it = PageIterator(range(12))
+    it.slice(3, 4)
+    assert it.total_count == 12
+    assert len(it) == 4
+    assert list(it) == list(range(3, 7))
+
+
+def test_natural_sort():
+    it = PageIterator([4, 3, 1, 2, 0])
+    it.sort()
+    assert it.total_count == 5
+    assert len(it) == 5
+    assert list(it) == list(range(5))
+
+
+def test_natural_sort_reversed():
+    it = PageIterator([4, 3, 1, 2, 0])
+    it.sort(reverse=True)
+    assert it.total_count == 5
+    assert len(it) == 5
+    assert list(it) == list(reversed(range(5)))
+
+
+class TestItem(object):
+    def __init__(self, value):
+        self.name = str(value)
+        self.foo = value
+
+    def __eq__(self, other):
+        return other.name == self.name
+
+
+def test_setting_sort():
+    it = PageIterator([TestItem(v) for v in [4, 3, 1, 2, 0]])
+    it.sort('foo')
+    assert it.total_count == 5
+    assert len(it) == 5
+    assert list(it) == [TestItem(v) for v in range(5)]
+
+
+def test_setting_sort_reversed():
+    it = PageIterator([TestItem(v) for v in [4, 3, 1, 2, 0]])
+    it.sort('foo', reverse=True)
+    assert it.total_count == 5
+    assert len(it) == 5
+    assert list(it) == [TestItem(v) for v in reversed(range(5))]
+
+
+def test_filter():
+    page = mock.MagicMock(spec=Page)
+    page.config = PageConfiguration()
+    page.config.set('threes', {'is_foo': 3})
+    it = PageIterator([TestItem(v) for v in [3, 2, 3, 1, 4, 3]],
+                      current_page=page)
+    it.filter('threes')
+    assert it.total_count == 3
+    assert len(it) == 3
+    assert list(it) == [TestItem(3), TestItem(3), TestItem(3)]
+
+
+def test_magic_filter():
+    it = PageIterator([TestItem(v) for v in [3, 2, 3, 1, 4, 3]])
+    it.is_foo(3)
+    assert it.total_count == 3
+    assert len(it) == 3
+    assert list(it) == [TestItem(3), TestItem(3), TestItem(3)]
+
--- a/tests/test_page.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_page.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,72 +1,67 @@
 import pytest
-from piecrust.page import parse_segments
-
+from piecrust.page import parse_segments, _count_lines
 
 
 test_parse_segments_data1 = ("", {'content': ''})
 test_parse_segments_data2 = ("Foo bar", {'content': 'Foo bar'})
-test_parse_segments_data3 = ("""Something that spans
-several lines
-like this""",
-        {'content': """Something that spans
+test_parse_segments_data3 = (
+    """Something that spans
+    several lines
+    like this""",
+    {'content': """Something that spans
 several lines
 like this"""})
-test_parse_segments_data4 = ("""Blah blah
----foo---
-Something else
----bar---
-Last thing
-""",
-        {
-            'content': "Blah blah\n",
-            'foo': "Something else\n",
-            'bar': "Last thing\n"})
-test_parse_segments_data5 = ("""Blah blah
-<--textile-->
-Here's some textile
-""",
-        {
-            'content': [
-                ("Blah blah\n", None),
-                ("Here's some textile\n", 'textile')]})
-test_parse_segments_data6 = ("""Blah blah
-Whatever
-<--textile-->
-Oh well, that's good
----foo---
-Another segment
-With another...
-<--change-->
-...of formatting.
-""",
-        {
-            'content': [
-                ("Blah blah\nWhatever\n", None),
-                ("Oh well, that's good\n", 'textile')],
-            'foo': [
-                ("Another segment\nWith another...\n", None),
-                ("...of formatting.\n", 'change')]})
+test_parse_segments_data4 = (
+    """Blah blah
+    ---foo---
+    Something else
+    ---bar---
+    Last thing
+    """,
+    {
+        'content': "Blah blah\n",
+        'foo': "Something else\n",
+        'bar': "Last thing\n"})
+
 
 @pytest.mark.parametrize('text, expected', [
-        test_parse_segments_data1,
-        test_parse_segments_data2,
-        test_parse_segments_data3,
-        test_parse_segments_data4,
-        test_parse_segments_data5,
-        test_parse_segments_data6,
-    ])
+    test_parse_segments_data1,
+    test_parse_segments_data2,
+    test_parse_segments_data3,
+    test_parse_segments_data4,
+])
 def test_parse_segments(text, expected):
     actual = parse_segments(text)
     assert actual is not None
     assert list(actual.keys()) == list(expected.keys())
     for key, val in expected.items():
-        if isinstance(val, str):
-            assert len(actual[key].parts) == 1
-            assert actual[key].parts[0].content == val
-            assert actual[key].parts[0].fmt is None
-        else:
-            assert len(actual[key].parts) == len(val)
-            for i, part in enumerate(val):
-                assert actual[key].parts[i].content == part[0]
-                assert actual[key].parts[i].fmt == part[1]
+        assert actual[key].content == val
+        assert actual[key].fmt is None
+
 
+@pytest.mark.parametrize('text, expected', [
+    ('', 1),
+    ('\n', 2),
+    ('blah foo', 1),
+    ('blah foo\n', 2),
+    ('blah foo\nmore here', 2),
+    ('blah foo\nmore here\n', 3),
+    ('\nblah foo\nmore here\n', 3),
+])
+def test_count_lines(text, expected):
+    actual = _count_lines(text)
+    assert actual == expected
+
+
+@pytest.mark.parametrize('text, start, end, expected', [
+    ('', 0, -1, 1),
+    ('\n', 1, -1, 1),
+    ('blah foo', 2, 4, 1),
+    ('blah foo\n', 2, 4, 1),
+    ('blah foo\nmore here', 4, -1, 2),
+    ('blah foo\nmore here\n', 10, -1, 2),
+    ('\nblah foo\nmore here\n', 2, -1, 3),
+])
+def test_count_lines_with_offsets(text, start, end, expected):
+    actual = _count_lines(text)
+    assert actual == expected
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_pipelines_asset.py	Tue Oct 17 01:07:30 2017 -0700
@@ -0,0 +1,233 @@
+import time
+import os.path
+import shutil
+import inspect
+import pytest
+from piecrust.pipelines.asset import get_filtered_processors
+from piecrust.pipelines.records import MultiRecord
+from piecrust.processing.base import SimpleFileProcessor
+from .mockutil import mock_fs, mock_fs_scope
+
+
+class FooProcessor(SimpleFileProcessor):
+    def __init__(self, exts=None, open_func=None):
+        exts = exts or {'foo', 'foo'}
+        super(FooProcessor, self).__init__({exts[0]: exts[1]})
+        self.PROCESSOR_NAME = exts[0]
+        self.open_func = open_func or open
+
+    def _doProcess(self, in_path, out_path):
+        with self.open_func(in_path, 'r') as f:
+            text = f.read()
+        with self.open_func(out_path, 'w') as f:
+            f.write("%s: %s" % (self.PROCESSOR_NAME.upper(), text))
+        return True
+
+
+class NoopProcessor(SimpleFileProcessor):
+    def __init__(self, exts):
+        super(NoopProcessor, self).__init__({exts[0]: exts[1]})
+        self.PROCESSOR_NAME = exts[0]
+        self.processed = []
+
+    def _doProcess(self, in_path, out_path):
+        self.processed.append(in_path)
+        shutil.copyfile(in_path, out_path)
+        return True
+
+
+def _get_test_fs(processors=None):
+    if processors is None:
+        processors = 'copy'
+    return (mock_fs()
+            .withDir('counter')
+            .withConfig({
+                'pipelines': {
+                    'asset': {
+                        'processors': processors
+                    }
+                }
+            }))
+
+
+def _create_test_plugin(fs, *, foo_exts=None, noop_exts=None):
+    src = [
+        'from piecrust.plugins.base import PieCrustPlugin',
+        'from piecrust.processing.base import SimpleFileProcessor']
+
+    foo_lines = inspect.getsourcelines(FooProcessor)
+    src += ['']
+    src += map(lambda l: l.rstrip('\n'), foo_lines[0])
+
+    noop_lines = inspect.getsourcelines(NoopProcessor)
+    src += ['']
+    src += map(lambda l: l.rstrip('\n'), noop_lines[0])
+
+    src += [
+        '',
+        'class FooNoopPlugin(PieCrustPlugin):',
+        '    def getProcessors(self):',
+        '        yield FooProcessor(%s)' % repr(foo_exts),
+        '        yield NoopProcessor(%s)' % repr(noop_exts),
+        '',
+        '__piecrust_plugin__ = FooNoopPlugin']
+
+    fs.withFile('kitchen/plugins/foonoop.py', src)
+
+
+def _bake_assets(fs):
+    fs.runChef('bake', '-p', 'asset')
+
+
+def test_empty():
+    fs = _get_test_fs()
+    with mock_fs_scope(fs):
+        expected = {}
+        assert expected == fs.getStructure('counter')
+        _bake_assets(fs)
+        expected = {}
+        assert expected == fs.getStructure('counter')
+
+
+def test_one_file():
+    fs = (_get_test_fs()
+          .withFile('kitchen/assets/something.html', 'A test file.'))
+    with mock_fs_scope(fs):
+        expected = {}
+        assert expected == fs.getStructure('counter')
+        _bake_assets(fs)
+        expected = {'something.html': 'A test file.'}
+        assert expected == fs.getStructure('counter')
+
+
+def test_one_level_dirtyness():
+    fs = (_get_test_fs()
+          .withFile('kitchen/assets/blah.foo', 'A test file.'))
+    with mock_fs_scope(fs):
+        _bake_assets(fs)
+        expected = {'blah.foo': 'A test file.'}
+        assert expected == fs.getStructure('counter')
+        mtime = os.path.getmtime(fs.path('/counter/blah.foo'))
+        assert abs(time.time() - mtime) <= 2
+
+        time.sleep(1)
+        _bake_assets(fs)
+        assert expected == fs.getStructure('counter')
+        assert mtime == os.path.getmtime(fs.path('/counter/blah.foo'))
+
+        time.sleep(1)
+        fs.withFile('kitchen/assets/blah.foo', 'A new test file.')
+        _bake_assets(fs)
+        expected = {'blah.foo': 'A new test file.'}
+        assert expected == fs.getStructure('counter')
+        assert mtime < os.path.getmtime(fs.path('/counter/blah.foo'))
+
+
+def test_two_levels_dirtyness():
+    fs = (_get_test_fs()
+          .withFile('kitchen/assets/blah.foo', 'A test file.'))
+    _create_test_plugin(fs, foo_exts=('foo', 'bar'))
+    with mock_fs_scope(fs):
+        _bake_assets(fs)
+        expected = {'blah.bar': 'FOO: A test file.'}
+        assert expected == fs.getStructure('counter')
+        mtime = os.path.getmtime(fs.path('/counter/blah.bar'))
+        assert abs(time.time() - mtime) <= 2
+
+        time.sleep(1)
+        _bake_assets(fs)
+        assert expected == fs.getStructure('counter')
+        assert mtime == os.path.getmtime(fs.path('/counter/blah.bar'))
+
+        time.sleep(1)
+        fs.withFile('kitchen/assets/blah.foo', 'A new test file.')
+        _bake_assets(fs)
+        expected = {'blah.bar': 'FOO: A new test file.'}
+        assert expected == fs.getStructure('counter')
+        assert mtime < os.path.getmtime(fs.path('/counter/blah.bar'))
+
+
+def test_removed():
+    fs = (_get_test_fs()
+          .withFile('kitchen/assets/blah1.foo', 'A test file.')
+          .withFile('kitchen/assets/blah2.foo', 'Ooops'))
+    with mock_fs_scope(fs):
+        expected = {
+            'blah1.foo': 'A test file.',
+            'blah2.foo': 'Ooops'}
+        assert expected == fs.getStructure('kitchen/assets')
+        _bake_assets(fs)
+        assert expected == fs.getStructure('counter')
+
+        time.sleep(1)
+        os.remove(fs.path('/kitchen/assets/blah2.foo'))
+        expected = {
+            'blah1.foo': 'A test file.'}
+        assert expected == fs.getStructure('kitchen/assets')
+        _bake_assets(1)
+        assert expected == fs.getStructure('counter')
+
+
+def test_record_version_change():
+    fs = (_get_test_fs()
+          .withFile('kitchen/assets/blah.foo', 'A test file.'))
+    _create_test_plugin(fs, foo_exts=('foo', 'foo'))
+    with mock_fs_scope(fs):
+        _bake_assets(fs)
+        assert os.path.exists(fs.path('/counter/blah.foo')) is True
+        mtime = os.path.getmtime(fs.path('/counter/blah.foo'))
+
+        time.sleep(1)
+        _bake_assets(fs)
+        assert mtime == os.path.getmtime(fs.path('/counter/blah.foo'))
+
+        time.sleep(1)
+        MultiRecord.RECORD_VERSION += 1
+        try:
+            _bake_assets(fs)
+            assert mtime < os.path.getmtime(fs.path('/counter/blah.foo'))
+        finally:
+            MultiRecord.RECORD_VERSION -= 1
+
+
+@pytest.mark.parametrize('patterns, expected', [
+    (['_'],
+     {'something.html': 'A test file.'}),
+    (['html'],
+     {}),
+    (['/^_/'],
+     {'something.html': 'A test file.',
+      'foo': {'_important.html': 'Important!'}})
+])
+def test_ignore_pattern(patterns, expected):
+    fs = (_get_test_fs()
+          .withFile('kitchen/assets/something.html', 'A test file.')
+          .withFile('kitchen/assets/_hidden.html', 'Shhh')
+          .withFile('kitchen/assets/foo/_important.html', 'Important!'))
+    fs.withConfig({'pipelines': {'asset': {'ignore': patterns}}})
+    with mock_fs_scope(fs):
+        assert {} == fs.getStructure('counter')
+        _bake_assets(fs)
+        assert expected == fs.getStructure('counter')
+
+
+@pytest.mark.parametrize('names, expected', [
+    ('all', ['cleancss', 'compass', 'copy', 'concat', 'less', 'requirejs',
+             'sass', 'sitemap', 'uglifyjs', 'pygments_style']),
+    ('all -sitemap', ['cleancss', 'copy', 'compass', 'concat', 'less',
+                      'requirejs', 'sass', 'uglifyjs', 'pygments_style']),
+    ('-sitemap -less -sass all', ['cleancss', 'copy', 'compass', 'concat',
+                                  'requirejs', 'uglifyjs',
+                                  'pygments_style']),
+    ('copy', ['copy']),
+    ('less sass', ['less', 'sass'])
+])
+def test_filter_processor(names, expected):
+    fs = mock_fs().withConfig()
+    with mock_fs_scope(fs):
+        app = fs.getApp()
+        processors = app.plugin_loader.getProcessors()
+        procs = get_filtered_processors(processors, names)
+        actual = [p.PROCESSOR_NAME for p in procs]
+        assert sorted(actual) == sorted(expected)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_pipelines_page.py	Tue Oct 17 01:07:30 2017 -0700
@@ -0,0 +1,98 @@
+import time
+import os.path
+import urllib.parse
+import pytest
+from piecrust.pipelines.records import MultiRecord
+from piecrust.pipelines._pagebaker import PageBaker
+from .mockutil import get_mock_app, mock_fs, mock_fs_scope
+
+
+@pytest.mark.parametrize('uri, pretty, expected', [
+    # Pretty URLs
+    ('', True, 'index.html'),
+    ('2', True, '2/index.html'),
+    ('foo', True, 'foo/index.html'),
+    ('foo/2', True, 'foo/2/index.html'),
+    ('foo/bar', True, 'foo/bar/index.html'),
+    ('foo/bar/2', True, 'foo/bar/2/index.html'),
+    ('foo.ext', True, 'foo.ext/index.html'),
+    ('foo.ext/2', True, 'foo.ext/2/index.html'),
+    ('foo/bar.ext', True, 'foo/bar.ext/index.html'),
+    ('foo/bar.ext/2', True, 'foo/bar.ext/2/index.html'),
+    ('foo.bar.ext', True, 'foo.bar.ext/index.html'),
+    ('foo.bar.ext/2', True, 'foo.bar.ext/2/index.html'),
+    # Ugly URLs
+    ('', False, 'index.html'),
+    ('2.html', False, '2.html'),
+    ('foo.html', False, 'foo.html'),
+    ('foo/2.html', False, 'foo/2.html'),
+    ('foo/bar.html', False, 'foo/bar.html'),
+    ('foo/bar/2.html', False, 'foo/bar/2.html'),
+    ('foo.ext', False, 'foo.ext'),
+    ('foo/2.ext', False, 'foo/2.ext'),
+    ('foo/bar.ext', False, 'foo/bar.ext'),
+    ('foo/bar/2.ext', False, 'foo/bar/2.ext'),
+    ('foo.bar.ext', False, 'foo.bar.ext'),
+    ('foo.bar/2.ext', False, 'foo.bar/2.ext')
+])
+def test_get_output_path(uri, pretty, expected):
+    app = get_mock_app()
+    if pretty:
+        app.config.set('site/pretty_urls', True)
+    assert app.config.get('site/pretty_urls') == pretty
+
+    for site_root in ['/', '/whatever/', '/~johndoe/']:
+        app.config.set('site/root', urllib.parse.quote(site_root))
+        baker = PageBaker(app, '/destination')
+        try:
+            path = baker.getOutputPath(urllib.parse.quote(site_root) + uri,
+                                       pretty)
+            expected = os.path.normpath(
+                os.path.join('/destination', expected))
+            assert expected == path
+        finally:
+            baker.shutdown()
+
+
+def test_removed():
+    fs = (mock_fs()
+          .withConfig()
+          .withPage('pages/foo.md', {'layout': 'none', 'format': 'none'},
+                    "a foo page")
+          .withPage('pages/_index.md', {'layout': 'none', 'format': 'none'},
+                    "something"))
+    with mock_fs_scope(fs):
+        fs.runChef('bake')
+        structure = fs.getStructure('kitchen/_counter')
+        assert structure == {
+            'foo.html': 'a foo page',
+            'index.html': 'something'}
+
+        os.remove(fs.path('kitchen/pages/foo.md'))
+        fs.runChef('bake')
+        structure = fs.getStructure('kitchen/_counter')
+        assert structure == {
+            'index.html': 'something'}
+
+
+def test_record_version_change():
+    fs = (mock_fs()
+          .withConfig()
+          .withPage('pages/foo.md', {'layout': 'none', 'format': 'none'},
+                    'a foo page'))
+    with mock_fs_scope(fs):
+        fs.runChef('bake')
+        mtime = os.path.getmtime(fs.path('kitchen/_counter/foo.html'))
+        time.sleep(1)
+
+        fs.runChef('bake')
+        assert mtime == os.path.getmtime(fs.path('kitchen/_counter/foo.html'))
+
+        MultiRecord.RECORD_VERSION += 1
+        try:
+            fs.runChef('bake')
+            assert mtime < os.path.getmtime(fs.path(
+                'kitchen/_counter/foo.html'))
+        finally:
+            MultiRecord.RECORD_VERSION -= 1
+
--- a/tests/test_processing_base.py	Tue Oct 17 01:04:10 2017 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,221 +0,0 @@
-import time
-import os.path
-import shutil
-import pytest
-from piecrust.processing.base import SimpleFileProcessor
-from piecrust.processing.pipeline import ProcessorPipeline
-from piecrust.processing.records import ProcessorPipelineRecord
-from piecrust.processing.worker import get_filtered_processors
-from .mockutil import mock_fs, mock_fs_scope
-
-
-class FooProcessor(SimpleFileProcessor):
-    def __init__(self, exts=None, open_func=None):
-        exts = exts or {'foo', 'foo'}
-        super(FooProcessor, self).__init__({exts[0]: exts[1]})
-        self.PROCESSOR_NAME = exts[0]
-        self.open_func = open_func or open
-
-    def _doProcess(self, in_path, out_path):
-        with self.open_func(in_path, 'r') as f:
-            text = f.read()
-        with self.open_func(out_path, 'w') as f:
-            f.write("%s: %s" % (self.PROCESSOR_NAME.upper(), text))
-        return True
-
-
-class NoopProcessor(SimpleFileProcessor):
-    def __init__(self, exts):
-        super(NoopProcessor, self).__init__({exts[0]: exts[1]})
-        self.PROCESSOR_NAME = exts[0]
-        self.processed = []
-
-    def _doProcess(self, in_path, out_path):
-        self.processed.append(in_path)
-        shutil.copyfile(in_path, out_path)
-        return True
-
-
-def _get_pipeline(fs, app=None):
-    app = app or fs.getApp()
-    return ProcessorPipeline(app, fs.path('counter'))
-
-
-def test_empty():
-    fs = (mock_fs()
-            .withDir('counter')
-            .withConfig())
-    with mock_fs_scope(fs):
-        pp = _get_pipeline(fs)
-        pp.enabled_processors = ['copy']
-        expected = {}
-        assert expected == fs.getStructure('counter')
-        pp.run()
-        expected = {}
-        assert expected == fs.getStructure('counter')
-
-
-def test_one_file():
-    fs = (mock_fs()
-            .withDir('counter')
-            .withConfig()
-            .withFile('kitchen/assets/something.html', 'A test file.'))
-    with mock_fs_scope(fs):
-        pp = _get_pipeline(fs)
-        pp.enabled_processors = ['copy']
-        expected = {}
-        assert expected == fs.getStructure('counter')
-        pp.run()
-        expected = {'something.html': 'A test file.'}
-        assert expected == fs.getStructure('counter')
-
-
-def test_one_level_dirtyness():
-    fs = (mock_fs()
-            .withConfig()
-            .withFile('kitchen/assets/blah.foo', 'A test file.'))
-    with mock_fs_scope(fs):
-        pp = _get_pipeline(fs)
-        pp.enabled_processors = ['copy']
-        pp.run()
-        expected = {'blah.foo': 'A test file.'}
-        assert expected == fs.getStructure('counter')
-        mtime = os.path.getmtime(fs.path('/counter/blah.foo'))
-        assert abs(time.time() - mtime) <= 2
-
-        time.sleep(1)
-        pp.run()
-        assert expected == fs.getStructure('counter')
-        assert mtime == os.path.getmtime(fs.path('/counter/blah.foo'))
-
-        time.sleep(1)
-        fs.withFile('kitchen/assets/blah.foo', 'A new test file.')
-        pp.run()
-        expected = {'blah.foo': 'A new test file.'}
-        assert expected == fs.getStructure('counter')
-        assert mtime < os.path.getmtime(fs.path('/counter/blah.foo'))
-
-
-def test_two_levels_dirtyness():
-    fs = (mock_fs()
-            .withConfig()
-            .withFile('kitchen/assets/blah.foo', 'A test file.'))
-    with mock_fs_scope(fs):
-        pp = _get_pipeline(fs)
-        pp.enabled_processors = ['copy']
-        pp.additional_processors_factories = [
-                lambda: FooProcessor(('foo', 'bar'))]
-        pp.run()
-        expected = {'blah.bar': 'FOO: A test file.'}
-        assert expected == fs.getStructure('counter')
-        mtime = os.path.getmtime(fs.path('/counter/blah.bar'))
-        assert abs(time.time() - mtime) <= 2
-
-        time.sleep(1)
-        pp.run()
-        assert expected == fs.getStructure('counter')
-        assert mtime == os.path.getmtime(fs.path('/counter/blah.bar'))
-
-        time.sleep(1)
-        fs.withFile('kitchen/assets/blah.foo', 'A new test file.')
-        pp.run()
-        expected = {'blah.bar': 'FOO: A new test file.'}
-        assert expected == fs.getStructure('counter')
-        assert mtime < os.path.getmtime(fs.path('/counter/blah.bar'))
-
-
-def test_removed():
-    fs = (mock_fs()
-            .withConfig()
-            .withFile('kitchen/assets/blah1.foo', 'A test file.')
-            .withFile('kitchen/assets/blah2.foo', 'Ooops'))
-    with mock_fs_scope(fs):
-        expected = {
-                'blah1.foo': 'A test file.',
-                'blah2.foo': 'Ooops'}
-        assert expected == fs.getStructure('kitchen/assets')
-        pp = _get_pipeline(fs)
-        pp.enabled_processors = ['copy']
-        pp.run()
-        assert expected == fs.getStructure('counter')
-
-        time.sleep(1)
-        os.remove(fs.path('/kitchen/assets/blah2.foo'))
-        expected = {
-                'blah1.foo': 'A test file.'}
-        assert expected == fs.getStructure('kitchen/assets')
-        pp.run()
-        assert expected == fs.getStructure('counter')
-
-
-def test_record_version_change():
-    fs = (mock_fs()
-            .withConfig()
-            .withFile('kitchen/assets/blah.foo', 'A test file.'))
-    with mock_fs_scope(fs):
-        pp = _get_pipeline(fs)
-        pp.enabled_processors = ['copy']
-        pp.additional_processors_factories = [
-                lambda: NoopProcessor(('foo', 'foo'))]
-        pp.run()
-        assert os.path.exists(fs.path('/counter/blah.foo')) is True
-        mtime = os.path.getmtime(fs.path('/counter/blah.foo'))
-
-        time.sleep(1)
-        pp.run()
-        assert mtime == os.path.getmtime(fs.path('/counter/blah.foo'))
-
-        time.sleep(1)
-        ProcessorPipelineRecord.RECORD_VERSION += 1
-        try:
-            pp.run()
-            assert mtime < os.path.getmtime(fs.path('/counter/blah.foo'))
-        finally:
-            ProcessorPipelineRecord.RECORD_VERSION -= 1
-
-
-@pytest.mark.parametrize('patterns, expected', [
-        (['_'],
-            {'something.html': 'A test file.'}),
-        (['html'],
-            {}),
-        (['/^_/'],
-            {'something.html': 'A test file.',
-                'foo': {'_important.html': 'Important!'}})
-        ])
-def test_ignore_pattern(patterns, expected):
-    fs = (mock_fs()
-            .withDir('counter')
-            .withConfig()
-            .withFile('kitchen/assets/something.html', 'A test file.')
-            .withFile('kitchen/assets/_hidden.html', 'Shhh')
-            .withFile('kitchen/assets/foo/_important.html', 'Important!'))
-    with mock_fs_scope(fs):
-        pp = _get_pipeline(fs)
-        pp.addIgnorePatterns(patterns)
-        pp.enabled_processors = ['copy']
-        assert {} == fs.getStructure('counter')
-        pp.run()
-        assert expected == fs.getStructure('counter')
-
-
-@pytest.mark.parametrize('names, expected', [
-        ('all', ['cleancss', 'compass', 'copy', 'concat', 'less', 'requirejs',
-                 'sass', 'sitemap', 'uglifyjs', 'pygments_style']),
-        ('all -sitemap', ['cleancss', 'copy', 'compass', 'concat', 'less',
-                          'requirejs', 'sass', 'uglifyjs', 'pygments_style']),
-        ('-sitemap -less -sass all', ['cleancss', 'copy', 'compass', 'concat',
-                                      'requirejs', 'uglifyjs',
-                                      'pygments_style']),
-        ('copy', ['copy']),
-        ('less sass', ['less', 'sass'])
-    ])
-def test_filter_processor(names, expected):
-    fs = mock_fs().withConfig()
-    with mock_fs_scope(fs):
-        app = fs.getApp()
-        processors = app.plugin_loader.getProcessors()
-        procs = get_filtered_processors(processors, names)
-        actual = [p.PROCESSOR_NAME for p in procs]
-        assert sorted(actual) == sorted(expected)
-
--- a/tests/test_processing_tree.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_processing_tree.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,5 +1,7 @@
-from piecrust.processing.base import CopyFileProcessor, SimpleFileProcessor
-from piecrust.processing.tree import ProcessingTreeBuilder, ProcessingTreeNode
+from piecrust.processing.base import SimpleFileProcessor
+from piecrust.processing.copy import CopyFileProcessor
+from piecrust.pipelines._proctree import (
+    ProcessingTreeBuilder, ProcessingTreeNode)
 
 
 class MockProcessor(SimpleFileProcessor):
--- a/tests/test_routing.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_routing.py	Tue Oct 17 01:07:30 2017 -0700
@@ -2,7 +2,7 @@
 import mock
 import pytest
 from piecrust.routing import Route, RouteParameter
-from piecrust.sources.base import PageSource
+from piecrust.sources.base import ContentSource
 from .mockutil import get_mock_app
 
 
@@ -20,24 +20,24 @@
         else:
             route_params.append(RouteParameter(p, RouteParameter.TYPE_STRING))
 
-    src = mock.MagicMock(spec=PageSource)
+    src = mock.MagicMock(spec=ContentSource)
     src.name = name
     src.getSupportedRouteParameters = lambda: route_params
     return src
 
 
 @pytest.mark.parametrize(
-        'config, metadata, params, expected',
-        [
-            ({'url': '/%foo%'},
-                {'foo': 'bar'}, ['foo'], True),
-            ({'url': '/%foo%'},
-                {'zoo': 'zar', 'foo': 'bar'}, ['foo'], True),
-            ({'url': '/%foo%'},
-                {'zoo': 'zar'}, ['foo'], False),
-            ({'url': '/%foo%/%zoo%'},
-                {'zoo': 'zar'}, ['foo', 'zoo'], False)
-            ])
+    'config, metadata, params, expected',
+    [
+        ({'url': '/%foo%'},
+         {'foo': 'bar'}, ['foo'], True),
+        ({'url': '/%foo%'},
+         {'zoo': 'zar', 'foo': 'bar'}, ['foo'], True),
+        ({'url': '/%foo%'},
+         {'zoo': 'zar'}, ['foo'], False),
+        ({'url': '/%foo%/%zoo%'},
+         {'zoo': 'zar'}, ['foo', 'zoo'], False)
+    ])
 def test_matches_metadata(config, metadata, params, expected):
     app = get_mock_app()
     app.config.set('site/root', '/')
@@ -50,21 +50,22 @@
 
 
 @pytest.mark.parametrize(
-        'site_root, route_pattern, params, expected_func_parameters',
-        [
-            ('/', '/%foo%', ['foo'], ['foo']),
-            ('/', '/%foo%', [('foo', 'path')], ['foo']),
-            ('/', '/%foo%/%bar%', ['foo', 'bar'], ['foo', 'bar']),
-            ('/', '/%foo%/%bar%', ['foo', ('bar', 'path')], ['foo', 'bar']),
-            ('/something', '/%foo%', ['foo'], ['foo']),
-            ('/something', '/%foo%', [('foo', 'path')], ['foo']),
-            ('/something', '/%foo%/%bar%', ['foo', 'bar'], ['foo', 'bar']),
-            ('/something', '/%foo%/%bar%', ['foo', ('bar', 'path')], ['foo', 'bar']),
-            ('/~johndoe', '/%foo%', ['foo'], ['foo']),
-            ('/~johndoe', '/%foo%', [('foo', 'path')], ['foo']),
-            ('/~johndoe', '/%foo%/%bar%', ['foo', 'bar'], ['foo', 'bar']),
-            ('/~johndoe', '/%foo%/%bar%', ['foo', ('bar', 'path')], ['foo', 'bar'])
-            ])
+    'site_root, route_pattern, params, expected_func_parameters',
+    [
+        ('/', '/%foo%', ['foo'], ['foo']),
+        ('/', '/%foo%', [('foo', 'path')], ['foo']),
+        ('/', '/%foo%/%bar%', ['foo', 'bar'], ['foo', 'bar']),
+        ('/', '/%foo%/%bar%', ['foo', ('bar', 'path')], ['foo', 'bar']),
+        ('/something', '/%foo%', ['foo'], ['foo']),
+        ('/something', '/%foo%', [('foo', 'path')], ['foo']),
+        ('/something', '/%foo%/%bar%', ['foo', 'bar'], ['foo', 'bar']),
+        ('/something', '/%foo%/%bar%', ['foo', ('bar', 'path')],
+         ['foo', 'bar']),
+        ('/~johndoe', '/%foo%', ['foo'], ['foo']),
+        ('/~johndoe', '/%foo%', [('foo', 'path')], ['foo']),
+        ('/~johndoe', '/%foo%/%bar%', ['foo', 'bar'], ['foo', 'bar']),
+        ('/~johndoe', '/%foo%/%bar%', ['foo', ('bar', 'path')], ['foo', 'bar'])
+    ])
 def test_required_metadata(site_root, route_pattern, params,
                            expected_func_parameters):
     app = get_mock_app()
@@ -77,95 +78,95 @@
 
 
 @pytest.mark.parametrize(
-        'site_root, config, params, uri, expected_match',
-        [
-            ('/', {'url': '/%foo%'},
-                ['foo'],
-                'something',
-                {'foo': 'something'}),
-            ('/', {'url': '/%foo%'},
-                ['foo'],
-                'something/other',
-                None),
-            ('/', {'url': '/%foo%'},
-                [('foo', 'path')],
-                'something/other',
-                {'foo': 'something/other'}),
-            ('/', {'url': '/%foo%'},
-                [('foo', 'path')],
-                '',
-                {'foo': ''}),
-            ('/', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix/something/other',
-                {'foo': 'something/other'}),
-            ('/', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix/',
-                {'foo': ''}),
-            ('/', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix',
-                {'foo': ''}),
+    'site_root, config, params, uri, expected_match',
+    [
+        ('/', {'url': '/%foo%'},
+         ['foo'],
+         'something',
+         {'foo': 'something'}),
+        ('/', {'url': '/%foo%'},
+         ['foo'],
+         'something/other',
+         None),
+        ('/', {'url': '/%foo%'},
+         [('foo', 'path')],
+         'something/other',
+         {'foo': 'something/other'}),
+        ('/', {'url': '/%foo%'},
+         [('foo', 'path')],
+         '',
+         {'foo': ''}),
+        ('/', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix/something/other',
+         {'foo': 'something/other'}),
+        ('/', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix/',
+         {'foo': ''}),
+        ('/', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix',
+         {'foo': ''}),
 
-            ('/blah', {'url': '/%foo%'},
-                ['foo'],
-                'something',
-                {'foo': 'something'}),
-            ('/blah', {'url': '/%foo%'},
-                ['foo'],
-                'something/other',
-                None),
-            ('/blah', {'url': '/%foo%'},
-                [('foo', 'path')],
-                'something/other',
-                {'foo': 'something/other'}),
-            ('/blah', {'url': '/%foo%'},
-                [('foo', 'path')],
-                '',
-                {'foo': ''}),
-            ('/blah', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix/something/other',
-                {'foo': 'something/other'}),
-            ('/blah', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix/',
-                {'foo': ''}),
-            ('/blah', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix',
-                {'foo': ''}),
+        ('/blah', {'url': '/%foo%'},
+         ['foo'],
+         'something',
+         {'foo': 'something'}),
+        ('/blah', {'url': '/%foo%'},
+         ['foo'],
+         'something/other',
+         None),
+        ('/blah', {'url': '/%foo%'},
+         [('foo', 'path')],
+         'something/other',
+         {'foo': 'something/other'}),
+        ('/blah', {'url': '/%foo%'},
+         [('foo', 'path')],
+         '',
+         {'foo': ''}),
+        ('/blah', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix/something/other',
+         {'foo': 'something/other'}),
+        ('/blah', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix/',
+         {'foo': ''}),
+        ('/blah', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix',
+         {'foo': ''}),
 
-            ('/~johndoe', {'url': '/%foo%'},
-                ['foo'],
-                'something',
-                {'foo': 'something'}),
-            ('/~johndoe', {'url': '/%foo%'},
-                ['foo'],
-                'something/other',
-                None),
-            ('/~johndoe', {'url': '/%foo%'},
-                [('foo', 'path')],
-                'something/other',
-                {'foo': 'something/other'}),
-            ('/~johndoe', {'url': '/%foo%'},
-                [('foo', 'path')],
-                '',
-                {'foo': ''}),
-            ('/~johndoe', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix/something/other',
-                {'foo': 'something/other'}),
-            ('/~johndoe', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix/',
-                {'foo': ''}),
-            ('/~johndoe', {'url': '/prefix/%foo%'},
-                [('foo', 'path')],
-                'prefix',
-                {'foo': ''}),
-            ])
+        ('/~johndoe', {'url': '/%foo%'},
+         ['foo'],
+         'something',
+         {'foo': 'something'}),
+        ('/~johndoe', {'url': '/%foo%'},
+         ['foo'],
+         'something/other',
+         None),
+        ('/~johndoe', {'url': '/%foo%'},
+         [('foo', 'path')],
+         'something/other',
+         {'foo': 'something/other'}),
+        ('/~johndoe', {'url': '/%foo%'},
+         [('foo', 'path')],
+         '',
+         {'foo': ''}),
+        ('/~johndoe', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix/something/other',
+         {'foo': 'something/other'}),
+        ('/~johndoe', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix/',
+         {'foo': ''}),
+        ('/~johndoe', {'url': '/prefix/%foo%'},
+         [('foo', 'path')],
+         'prefix',
+         {'foo': ''}),
+    ])
 def test_match_uri(site_root, config, params, uri, expected_match):
     site_root = site_root.rstrip('/') + '/'
     app = get_mock_app()
@@ -180,12 +181,12 @@
 
 
 @pytest.mark.parametrize(
-        'site_root',
-        [
-            ('/'),
-            ('/whatever'),
-            ('/~johndoe')
-            ])
+    'site_root',
+    [
+        ('/'),
+        ('/whatever'),
+        ('/~johndoe')
+    ])
 def test_match_uri_requires_absolute_uri(site_root):
     with pytest.raises(Exception):
         app = get_mock_app()
@@ -198,35 +199,35 @@
 
 
 @pytest.mark.parametrize(
-        'slug, page_num, pretty, expected',
-        [
-            # Pretty URLs
-            ('', 1, True, ''),
-            ('', 2, True, '2'),
-            ('foo', 1, True, 'foo'),
-            ('foo', 2, True, 'foo/2'),
-            ('foo/bar', 1, True, 'foo/bar'),
-            ('foo/bar', 2, True, 'foo/bar/2'),
-            ('foo.ext', 1, True, 'foo.ext'),
-            ('foo.ext', 2, True, 'foo.ext/2'),
-            ('foo/bar.ext', 1, True, 'foo/bar.ext'),
-            ('foo/bar.ext', 2, True, 'foo/bar.ext/2'),
-            ('foo.bar.ext', 1, True, 'foo.bar.ext'),
-            ('foo.bar.ext', 2, True, 'foo.bar.ext/2'),
-            # Ugly URLs
-            ('', 1, False, ''),
-            ('', 2, False, '2.html'),
-            ('foo', 1, False, 'foo.html'),
-            ('foo', 2, False, 'foo/2.html'),
-            ('foo/bar', 1, False, 'foo/bar.html'),
-            ('foo/bar', 2, False, 'foo/bar/2.html'),
-            ('foo.ext', 1, False, 'foo.ext'),
-            ('foo.ext', 2, False, 'foo/2.ext'),
-            ('foo/bar.ext', 1, False, 'foo/bar.ext'),
-            ('foo/bar.ext', 2, False, 'foo/bar/2.ext'),
-            ('foo.bar.ext', 1, False, 'foo.bar.ext'),
-            ('foo.bar.ext', 2, False, 'foo.bar/2.ext')
-            ])
+    'slug, page_num, pretty, expected',
+    [
+        # Pretty URLs
+        ('', 1, True, ''),
+        ('', 2, True, '2'),
+        ('foo', 1, True, 'foo'),
+        ('foo', 2, True, 'foo/2'),
+        ('foo/bar', 1, True, 'foo/bar'),
+        ('foo/bar', 2, True, 'foo/bar/2'),
+        ('foo.ext', 1, True, 'foo.ext'),
+        ('foo.ext', 2, True, 'foo.ext/2'),
+        ('foo/bar.ext', 1, True, 'foo/bar.ext'),
+        ('foo/bar.ext', 2, True, 'foo/bar.ext/2'),
+        ('foo.bar.ext', 1, True, 'foo.bar.ext'),
+        ('foo.bar.ext', 2, True, 'foo.bar.ext/2'),
+        # Ugly URLs
+        ('', 1, False, ''),
+        ('', 2, False, '2.html'),
+        ('foo', 1, False, 'foo.html'),
+        ('foo', 2, False, 'foo/2.html'),
+        ('foo/bar', 1, False, 'foo/bar.html'),
+        ('foo/bar', 2, False, 'foo/bar/2.html'),
+        ('foo.ext', 1, False, 'foo.ext'),
+        ('foo.ext', 2, False, 'foo/2.ext'),
+        ('foo/bar.ext', 1, False, 'foo/bar.ext'),
+        ('foo/bar.ext', 2, False, 'foo/bar/2.ext'),
+        ('foo.bar.ext', 1, False, 'foo.bar.ext'),
+        ('foo.bar.ext', 2, False, 'foo.bar/2.ext')
+    ])
 def test_get_uri(slug, page_num, pretty, expected):
     for root in ['/', '/blah/', '/~johndoe/']:
         app = get_mock_app()
--- a/tests/test_serving.py	Tue Oct 17 01:04:10 2017 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,145 +0,0 @@
-import re
-import pytest
-import mock
-from piecrust.data.filters import (
-        PaginationFilter, HasFilterClause, IsFilterClause,
-        page_value_accessor)
-from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
-from piecrust.serving.util import find_routes
-from piecrust.sources.base import REALM_USER, REALM_THEME
-from .mockutil import mock_fs, mock_fs_scope
-
-
-@pytest.mark.parametrize('uri, route_specs, expected',
-        [
-            ('/',
-                [{'src': 'pages', 'pat': '(?P<path>.*)'}],
-                [('pages', {'path': '/'})]),
-            ('/',
-                [{'src': 'pages', 'pat': '(?P<path>.*)'},
-                    {'src': 'theme', 'pat': '(?P<path>.*)', 'realm': REALM_THEME}],
-                [('pages', {'path': '/'}), ('theme', {'path': '/'})])
-            ])
-def test_find_routes(uri, route_specs, expected):
-    routes = []
-    for rs in route_specs:
-        m = mock.Mock()
-        m.source_name = rs['src']
-        m.source_realm = rs.setdefault('realm', REALM_USER)
-        m.uri_re = re.compile(rs['pat'])
-        m.matchUri = lambda u: m.uri_re.match(u).groupdict()
-        routes.append(m)
-    matching = find_routes(routes, uri)
-
-    assert len(matching) == len(expected)
-    for i in range(len(matching)):
-        route, metadata, is_sub_page = matching[i]
-        exp_source, exp_md = expected[i]
-        assert route.source_name == exp_source
-        assert metadata == exp_md
-
-
-@pytest.mark.parametrize(
-        'tag, expected_indices',
-        [
-            ('foo', [1, 2, 4, 5, 6]),
-            ('bar', [2, 3, 4, 6, 8]),
-            ('whatever', [5, 8]),
-            ('unique', [7]),
-            ('missing', None)
-        ])
-def test_serve_tag_page(tag, expected_indices):
-    tags = [
-            ['foo'],
-            ['foo', 'bar'],
-            ['bar'],
-            ['bar', 'foo'],
-            ['foo', 'whatever'],
-            ['foo', 'bar'],
-            ['unique'],
-            ['whatever', 'bar']]
-
-    def config_factory(i):
-        c = {'title': 'Post %d' % (i + 1)}
-        c['tags'] = list(tags[i])
-        return c
-
-    fs = (mock_fs()
-          .withConfig()
-          .withPages(8, 'posts/2015-03-{idx1:02}_post{idx1:02}.md',
-                     config_factory)
-          .withPage('pages/_tag.md', {'layout': 'none', 'format': 'none'},
-                    "Pages in {{tag}}\n"
-                    "{%for p in pagination.posts -%}\n"
-                    "{{p.title}}\n"
-                    "{%endfor%}"))
-    with mock_fs_scope(fs):
-        app = fs.getApp()
-        page = app.getSource('pages').getPage({'slug': '_tag', 'tag': tag})
-        route = app.getGeneratorRoute('posts_tags')
-        assert route is not None
-
-        route_metadata = {'slug': '_tag', 'tag': tag}
-        qp = QualifiedPage(page, route, route_metadata)
-        ctx = PageRenderingContext(qp)
-        route.generator.prepareRenderContext(ctx)
-        rp = render_page(ctx)
-
-        expected = "Pages in %s\n" % tag
-        if expected_indices:
-            for i in reversed(expected_indices):
-                expected += "Post %d\n" % i
-        assert expected == rp.content
-
-
-@pytest.mark.parametrize(
-        'category, expected_indices',
-        [
-            ('foo', [1, 2, 4]),
-            ('bar', [3, 6]),
-            ('missing', None)
-        ])
-def test_serve_category_page(category, expected_indices):
-    categories = [
-            'foo', 'foo', 'bar', 'foo', None, 'bar']
-
-    def config_factory(i):
-        c = {'title': 'Post %d' % (i + 1)}
-        if categories[i]:
-            c['category'] = categories[i]
-        return c
-
-    fs = (mock_fs()
-          .withConfig({
-              'site': {
-                  'taxonomies': {
-                      'categories': {'term': 'category'}
-                      }
-                  }
-              })
-          .withPages(6, 'posts/2015-03-{idx1:02}_post{idx1:02}.md',
-                     config_factory)
-          .withPage('pages/_category.md', {'layout': 'none', 'format': 'none'},
-                    "Pages in {{category}}\n"
-                    "{%for p in pagination.posts -%}\n"
-                    "{{p.title}}\n"
-                    "{%endfor%}"))
-    with mock_fs_scope(fs):
-        app = fs.getApp()
-        page = app.getSource('pages').getPage({'slug': '_category',
-                                               'category': category})
-        route = app.getGeneratorRoute('posts_categories')
-        assert route is not None
-
-        route_metadata = {'slug': '_category', 'category': category}
-        qp = QualifiedPage(page, route, route_metadata)
-        ctx = PageRenderingContext(qp)
-        route.generator.prepareRenderContext(ctx)
-        rp = render_page(ctx)
-
-        expected = "Pages in %s\n" % category
-        if expected_indices:
-            for i in reversed(expected_indices):
-                expected += "Post %d\n" % i
-        assert expected == rp.content
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_serving_util.py	Tue Oct 17 01:07:30 2017 -0700
@@ -0,0 +1,35 @@
+import re
+import pytest
+import mock
+from piecrust.serving.util import find_routes
+from piecrust.sources.base import REALM_USER, REALM_THEME
+
+
+@pytest.mark.parametrize(
+    'uri, route_specs, expected',
+    [
+        ('/',
+         [{'src': 'pages', 'pat': '(?P<path>.*)'}],
+         [('pages', {'path': '/'})]),
+        ('/',
+         [{'src': 'pages', 'pat': '(?P<path>.*)'},
+          {'src': 'theme', 'pat': '(?P<path>.*)', 'realm': REALM_THEME}],
+         [('pages', {'path': '/'}), ('theme', {'path': '/'})])
+    ])
+def test_find_routes(uri, route_specs, expected):
+    routes = []
+    for rs in route_specs:
+        m = mock.Mock()
+        m.source_name = rs['src']
+        m.source_realm = rs.setdefault('realm', REALM_USER)
+        m.uri_re = re.compile(rs['pat'])
+        m.matchUri = lambda u: m.uri_re.match(u).groupdict()
+        routes.append(m)
+    matching = find_routes(routes, uri)
+
+    assert len(matching) == len(expected)
+    for i in range(len(matching)):
+        route, metadata, is_sub_page = matching[i]
+        exp_source, exp_md = expected[i]
+        assert route.source_name == exp_source
+        assert metadata == exp_md
--- a/tests/test_sources_autoconfig.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_sources_autoconfig.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,62 +1,62 @@
+import os.path
 import pytest
-from piecrust.sources.base import MODE_PARSING
 from .mockutil import mock_fs, mock_fs_scope
 from .pathutil import slashfix
 
 
 @pytest.mark.parametrize(
-        'fs_fac, src_config, expected_paths, expected_metadata',
-        [
-            (lambda: mock_fs(), {}, [], []),
-            (lambda: mock_fs().withPage('test/_index.md'),
-                {},
-                ['_index.md'],
-                [{'slug': '', 'config': {'foo': []}}]),
-            (lambda: mock_fs().withPage('test/something.md'),
-                {},
-                ['something.md'],
-                [{'slug': 'something', 'config': {'foo': []}}]),
-            (lambda: mock_fs().withPage('test/bar/something.md'),
-                {},
-                ['bar/something.md'],
-                [{'slug': 'something', 'config': {'foo': ['bar']}}]),
-            (lambda: mock_fs().withPage('test/bar1/bar2/something.md'),
-                {},
-                ['bar1/bar2/something.md'],
-                [{'slug': 'something', 'config': {'foo': ['bar1', 'bar2']}}]),
+    'fs_fac, src_config, expected_paths, expected_metadata',
+    [
+        (lambda: mock_fs(), {}, [], []),
+        (lambda: mock_fs().withPage('test/_index.md'),
+         {},
+         ['_index.md'],
+         [{'slug': '', 'config': {'foo': []}}]),
+        (lambda: mock_fs().withPage('test/something.md'),
+         {},
+         ['something.md'],
+         [{'slug': 'something', 'config': {'foo': []}}]),
+        (lambda: mock_fs().withPage('test/bar/something.md'),
+         {},
+         ['bar/something.md'],
+         [{'slug': 'something', 'config': {'foo': ['bar']}}]),
+        (lambda: mock_fs().withPage('test/bar1/bar2/something.md'),
+         {},
+         ['bar1/bar2/something.md'],
+         [{'slug': 'something', 'config': {'foo': ['bar1', 'bar2']}}]),
 
-            (lambda: mock_fs().withPage('test/something.md'),
-                {'collapse_single_values': True},
-                ['something.md'],
-                [{'slug': 'something', 'config': {'foo': None}}]),
-            (lambda: mock_fs().withPage('test/bar/something.md'),
-                {'collapse_single_values': True},
-                ['bar/something.md'],
-                [{'slug': 'something', 'config': {'foo': 'bar'}}]),
-            (lambda: mock_fs().withPage('test/bar1/bar2/something.md'),
-                {'collapse_single_values': True},
-                ['bar1/bar2/something.md'],
-                [{'slug': 'something', 'config': {'foo': ['bar1', 'bar2']}}]),
+        (lambda: mock_fs().withPage('test/something.md'),
+         {'collapse_single_values': True},
+         ['something.md'],
+         [{'slug': 'something', 'config': {'foo': None}}]),
+        (lambda: mock_fs().withPage('test/bar/something.md'),
+         {'collapse_single_values': True},
+         ['bar/something.md'],
+         [{'slug': 'something', 'config': {'foo': 'bar'}}]),
+        (lambda: mock_fs().withPage('test/bar1/bar2/something.md'),
+         {'collapse_single_values': True},
+         ['bar1/bar2/something.md'],
+         [{'slug': 'something', 'config': {'foo': ['bar1', 'bar2']}}]),
 
-            (lambda: mock_fs().withPage('test/something.md'),
-                {'only_single_values': True},
-                ['something.md'],
-                [{'slug': 'something', 'config': {'foo': None}}]),
-            (lambda: mock_fs().withPage('test/bar/something.md'),
-                {'only_single_values': True},
-                ['bar/something.md'],
-                [{'slug': 'something', 'config': {'foo': 'bar'}}]),
-            ])
+        (lambda: mock_fs().withPage('test/something.md'),
+         {'only_single_values': True},
+         ['something.md'],
+         [{'slug': 'something', 'config': {'foo': None}}]),
+        (lambda: mock_fs().withPage('test/bar/something.md'),
+         {'only_single_values': True},
+         ['bar/something.md'],
+         [{'slug': 'something', 'config': {'foo': 'bar'}}]),
+    ])
 def test_autoconfig_source_factories(fs_fac, src_config, expected_paths,
                                      expected_metadata):
     site_config = {
-            'sources': {
-                'test': {'type': 'autoconfig',
-                         'setting_name': 'foo'}
-                },
-            'routes': [
-                {'url': '/%slug%', 'source': 'test'}]
-            }
+        'sources': {
+            'test': {'type': 'autoconfig',
+                     'setting_name': 'foo'}
+        },
+        'routes': [
+            {'url': '/%slug%', 'source': 'test'}]
+    }
     site_config['sources']['test'].update(src_config)
     fs = fs_fac()
     fs.withConfig({'site': site_config})
@@ -64,60 +64,60 @@
     with mock_fs_scope(fs):
         app = fs.getApp()
         s = app.getSource('test')
-        facs = list(s.buildPageFactories())
-        paths = [f.rel_path for f in facs]
+        items = list(s.getAllContents())
+        paths = [os.path.relpath(i.spec, s.fs_endpoint_path) for i in items]
         assert paths == slashfix(expected_paths)
-        metadata = [f.metadata for f in facs]
+        metadata = [i.metadata['route_params'] for i in items]
         assert metadata == expected_metadata
 
 
 def test_autoconfig_fails_if_multiple_folders():
     site_config = {
-            'sources': {
-                'test': {'type': 'autoconfig',
-                         'setting_name': 'foo',
-                         'only_single_values': True}
-                }
-            }
+        'sources': {
+            'test': {'type': 'autoconfig',
+                     'setting_name': 'foo',
+                     'only_single_values': True}
+        }
+    }
     fs = mock_fs().withConfig({'site': site_config})
     fs.withPage('test/bar1/bar2/something.md')
     with mock_fs_scope(fs):
         app = fs.getApp()
         s = app.getSource('test')
         with pytest.raises(Exception):
-            list(s.buildPageFactories())
+            list(s.getAllContents())
 
 
 @pytest.mark.parametrize(
-        'fs_fac, expected_paths, expected_metadata',
-        [
-            (lambda: mock_fs(), [], []),
-            (lambda: mock_fs().withPage('test/_index.md'),
-                ['_index.md'],
-                [{'slug': '',
-                    'config': {'foo': 0, 'foo_trail': [0]}}]),
-            (lambda: mock_fs().withPage('test/something.md'),
-                ['something.md'],
-                [{'slug': 'something',
-                    'config': {'foo': 0, 'foo_trail': [0]}}]),
-            (lambda: mock_fs().withPage('test/08_something.md'),
-                ['08_something.md'],
-                [{'slug': 'something',
-                    'config': {'foo': 8, 'foo_trail': [8]}}]),
-            (lambda: mock_fs().withPage('test/02_there/08_something.md'),
-                ['02_there/08_something.md'],
-                [{'slug': 'there/something',
-                    'config': {'foo': 8, 'foo_trail': [2, 8]}}]),
-            ])
+    'fs_fac, expected_paths, expected_metadata',
+    [
+        (lambda: mock_fs(), [], []),
+        (lambda: mock_fs().withPage('test/_index.md'),
+         ['_index.md'],
+         [{'slug': '',
+           'config': {'foo': 0, 'foo_trail': [0]}}]),
+        (lambda: mock_fs().withPage('test/something.md'),
+         ['something.md'],
+         [{'slug': 'something',
+           'config': {'foo': 0, 'foo_trail': [0]}}]),
+        (lambda: mock_fs().withPage('test/08_something.md'),
+         ['08_something.md'],
+         [{'slug': 'something',
+           'config': {'foo': 8, 'foo_trail': [8]}}]),
+        (lambda: mock_fs().withPage('test/02_there/08_something.md'),
+         ['02_there/08_something.md'],
+         [{'slug': 'there/something',
+           'config': {'foo': 8, 'foo_trail': [2, 8]}}]),
+    ])
 def test_ordered_source_factories(fs_fac, expected_paths, expected_metadata):
     site_config = {
-            'sources': {
-                'test': {'type': 'ordered',
-                         'setting_name': 'foo'}
-                },
-            'routes': [
-                {'url': '/%slug%', 'source': 'test'}]
-            }
+        'sources': {
+            'test': {'type': 'ordered',
+                     'setting_name': 'foo'}
+        },
+        'routes': [
+            {'url': '/%slug%', 'source': 'test'}]
+    }
     fs = fs_fac()
     fs.withConfig({'site': site_config})
     fs.withDir('kitchen/test')
@@ -132,43 +132,43 @@
 
 
 @pytest.mark.parametrize(
-        'fs_fac, route_path, expected_path, expected_metadata',
-        [
-            (lambda: mock_fs(), 'missing', None, None),
-            (lambda: mock_fs().withPage('test/something.md'),
-                'something', 'something.md',
-                {'slug': 'something',
-                    'config': {'foo': 0, 'foo_trail': [0]}}),
-            (lambda: mock_fs().withPage('test/bar/something.md'),
-                'bar/something', 'bar/something.md',
-                {'slug': 'bar/something',
-                    'config': {'foo': 0, 'foo_trail': [0]}}),
-            (lambda: mock_fs().withPage('test/42_something.md'),
-                'something', '42_something.md',
-                {'slug': 'something',
-                    'config': {'foo': 42, 'foo_trail': [42]}}),
-            (lambda: mock_fs().withPage('test/bar/42_something.md'),
-                'bar/something', 'bar/42_something.md',
-                {'slug': 'bar/something',
-                    'config': {'foo': 42, 'foo_trail': [42]}}),
+    'fs_fac, route_path, expected_path, expected_metadata',
+    [
+        (lambda: mock_fs(), 'missing', None, None),
+        (lambda: mock_fs().withPage('test/something.md'),
+         'something', 'something.md',
+         {'slug': 'something',
+          'config': {'foo': 0, 'foo_trail': [0]}}),
+        (lambda: mock_fs().withPage('test/bar/something.md'),
+         'bar/something', 'bar/something.md',
+         {'slug': 'bar/something',
+          'config': {'foo': 0, 'foo_trail': [0]}}),
+        (lambda: mock_fs().withPage('test/42_something.md'),
+         'something', '42_something.md',
+         {'slug': 'something',
+          'config': {'foo': 42, 'foo_trail': [42]}}),
+        (lambda: mock_fs().withPage('test/bar/42_something.md'),
+         'bar/something', 'bar/42_something.md',
+         {'slug': 'bar/something',
+          'config': {'foo': 42, 'foo_trail': [42]}}),
 
-            ((lambda: mock_fs()
-                .withPage('test/42_something.md')
-                .withPage('test/43_other_something.md')),
-                'something', '42_something.md',
-                {'slug': 'something',
-                    'config': {'foo': 42, 'foo_trail': [42]}}),
-            ])
+        ((lambda: mock_fs()
+          .withPage('test/42_something.md')
+          .withPage('test/43_other_something.md')),
+         'something', '42_something.md',
+         {'slug': 'something',
+          'config': {'foo': 42, 'foo_trail': [42]}}),
+    ])
 def test_ordered_source_find(fs_fac, route_path, expected_path,
                              expected_metadata):
     site_config = {
-            'sources': {
-                'test': {'type': 'ordered',
-                         'setting_name': 'foo'}
-                },
-            'routes': [
-                {'url': '/%slug%', 'source': 'test'}]
-            }
+        'sources': {
+            'test': {'type': 'ordered',
+                     'setting_name': 'foo'}
+        },
+        'routes': [
+            {'url': '/%slug%', 'source': 'test'}]
+    }
     fs = fs_fac()
     fs.withConfig({'site': site_config})
     fs.withDir('kitchen/test')
@@ -176,7 +176,7 @@
         app = fs.getApp()
         s = app.getSource('test')
         route_metadata = {'slug': route_path}
-        factory = s.findPageFactory(route_metadata, MODE_PARSING)
+        factory = s.findContent(route_metadata)
         if factory is None:
             assert expected_path is None and expected_metadata is None
             return
--- a/tests/test_sources_base.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_sources_base.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,26 +1,24 @@
 import os
 import pytest
 from piecrust.app import PieCrust
-from piecrust.sources.pageref import PageRef, PageNotFoundError
 from .mockutil import mock_fs, mock_fs_scope
-from .pathutil import slashfix
 
 
 @pytest.mark.parametrize('fs_fac, expected_paths, expected_slugs', [
-        (lambda: mock_fs(), [], []),
-        (lambda: mock_fs().withPage('test/foo.html'),
-            ['foo.html'], ['foo']),
-        (lambda: mock_fs().withPage('test/foo.md'),
-            ['foo.md'], ['foo']),
-        (lambda: mock_fs().withPage('test/foo.ext'),
-            ['foo.ext'], ['foo.ext']),
-        (lambda: mock_fs().withPage('test/foo/bar.html'),
-            ['foo/bar.html'], ['foo/bar']),
-        (lambda: mock_fs().withPage('test/foo/bar.md'),
-            ['foo/bar.md'], ['foo/bar']),
-        (lambda: mock_fs().withPage('test/foo/bar.ext'),
-            ['foo/bar.ext'], ['foo/bar.ext']),
-        ])
+    (lambda: mock_fs(), [], []),
+    (lambda: mock_fs().withPage('test/foo.html'),
+     ['foo.html'], ['foo']),
+    (lambda: mock_fs().withPage('test/foo.md'),
+     ['foo.md'], ['foo']),
+    (lambda: mock_fs().withPage('test/foo.ext'),
+     ['foo.ext'], ['foo.ext']),
+    (lambda: mock_fs().withPage('test/foo/bar.html'),
+     ['foo/bar.html'], ['foo/bar']),
+    (lambda: mock_fs().withPage('test/foo/bar.md'),
+     ['foo/bar.md'], ['foo/bar']),
+    (lambda: mock_fs().withPage('test/foo/bar.ext'),
+     ['foo/bar.ext'], ['foo/bar.ext']),
+])
 def test_default_source_factories(fs_fac, expected_paths, expected_slugs):
     fs = fs_fac()
     fs.withConfig({
@@ -29,8 +27,8 @@
                 'test': {}},
             'routes': [
                 {'url': '/%path%', 'source': 'test'}]
-            }
-        })
+        }
+    })
     fs.withDir('kitchen/test')
     with mock_fs_scope(fs):
         app = PieCrust(fs.path('kitchen'), cache=False)
@@ -43,12 +41,12 @@
 
 
 @pytest.mark.parametrize(
-        'ref_path, expected_path, expected_metadata',
-        [
-            ('foo.html', '/kitchen/test/foo.html', {'slug': 'foo'}),
-            ('foo/bar.html', '/kitchen/test/foo/bar.html',
-                {'slug': 'foo/bar'}),
-        ])
+    'ref_path, expected_path, expected_metadata',
+    [
+        ('foo.html', '/kitchen/test/foo.html', {'slug': 'foo'}),
+        ('foo/bar.html', '/kitchen/test/foo/bar.html',
+         {'slug': 'foo/bar'}),
+    ])
 def test_default_source_resolve_ref(ref_path, expected_path,
                                     expected_metadata):
     fs = mock_fs()
@@ -58,8 +56,8 @@
                 'test': {}},
             'routes': [
                 {'url': '/%path%', 'source': 'test'}]
-            }
-        })
+        }
+    })
     expected_path = fs.path(expected_path).replace('/', os.sep)
     with mock_fs_scope(fs):
         app = PieCrust(fs.path('kitchen'), cache=False)
@@ -67,87 +65,3 @@
         actual_path, actual_metadata = s.resolveRef(ref_path)
         assert actual_path == expected_path
         assert actual_metadata == expected_metadata
-
-
-@pytest.mark.parametrize('page_ref, expected_source_name, expected_rel_path, '
-                         'expected_possible_paths', [
-        ('foo:one.md', 'foo', 'one.md',
-            ['foo/one.md']),
-        ('foo:two.md', 'foo', 'two.md',
-            ['foo/two.md']),
-        ('foo:two.html', 'foo', 'two.html',
-            ['foo/two.html']),
-        ('foo:two.%ext%', 'foo', 'two.html',
-            ['foo/two.html', 'foo/two.md', 'foo/two.textile']),
-        ('foo:subdir/four.md', 'foo', 'subdir/four.md',
-            ['foo/subdir/four.md']),
-        ('foo:subdir/four.%ext%', 'foo', 'subdir/four.md',
-            ['foo/subdir/four.html', 'foo/subdir/four.md',
-             'foo/subdir/four.textile']),
-        ('foo:three.md;bar:three.md', 'foo', 'three.md',
-            ['foo/three.md', 'bar/three.md']),
-        ('foo:three.%ext%;bar:three.%ext%', 'foo', 'three.md',
-            ['foo/three.html', 'foo/three.md', 'foo/three.textile',
-             'bar/three.html', 'bar/three.md', 'bar/three.textile']),
-        ('foo:special.md;bar:special.md', 'bar', 'special.md',
-            ['foo/special.md', 'bar/special.md'])
-        ])
-def test_page_ref(page_ref, expected_source_name, expected_rel_path,
-                  expected_possible_paths):
-    fs = (mock_fs()
-            .withConfig({
-                'site': {
-                    'sources': {
-                        'foo': {},
-                        'bar': {}
-                        }
-                    }
-                })
-            .withPage('foo/one.md')
-            .withPage('foo/two.md')
-            .withPage('foo/two.html')
-            .withPage('foo/three.md')
-            .withPage('foo/subdir/four.md')
-            .withPage('bar/three.md')
-            .withPage('bar/special.md'))
-    with mock_fs_scope(fs):
-        app = fs.getApp()
-        r = PageRef(app, page_ref)
-
-        assert r.possible_paths == slashfix(
-                [os.path.join(fs.path('/kitchen'), p)
-                    for p in expected_possible_paths])
-
-        assert r.exists
-        assert r.source_name == expected_source_name
-        assert r.source == app.getSource(expected_source_name)
-        assert r.rel_path == expected_rel_path
-        assert r.path == slashfix(fs.path(os.path.join(
-                'kitchen', expected_source_name, expected_rel_path)))
-
-
-def test_page_ref_with_missing_source():
-    fs = mock_fs().withConfig()
-    with mock_fs_scope(fs):
-        app = fs.getApp()
-        r = PageRef(app, 'whatever:doesnt_exist.md')
-        with pytest.raises(Exception):
-            r.possible_ref_specs
-
-
-def test_page_ref_with_missing_file():
-    fs = mock_fs().withConfig()
-    with mock_fs_scope(fs):
-        app = fs.getApp()
-        r = PageRef(app, 'pages:doesnt_exist.%ext%')
-        assert r.possible_ref_specs == [
-                'pages:doesnt_exist.html', 'pages:doesnt_exist.md',
-                'pages:doesnt_exist.textile']
-        with pytest.raises(PageNotFoundError):
-            r.source_name
-        with pytest.raises(PageNotFoundError):
-            r.rel_path
-        with pytest.raises(PageNotFoundError):
-            r.path
-        assert not r.exists
-
--- a/tests/test_templating_jinjaengine.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_templating_jinjaengine.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,34 +1,34 @@
 import pytest
-from .mockutil import (
-        mock_fs, mock_fs_scope, get_simple_page, render_simple_page)
+from .mockutil import mock_fs, mock_fs_scope
+from .rdrutil import render_simple_page
 
 
 app_config = {
-        'site': {
-            'default_format': 'none',
-            'default_template_engine': 'jinja'},
-        'foo': 'bar'}
+    'site': {
+        'default_format': 'none',
+        'default_template_engine': 'jinja'},
+    'foo': 'bar'}
 page_config = {'layout': 'none'}
 
 open_patches = ['jinja2.environment', 'jinja2.utils']
 
 
 @pytest.mark.parametrize(
-        'contents, expected',
-        [
-            ("Raw text", "Raw text"),
-            ("This is {{foo}}", "This is bar"),
-            ("Info:\nMy URL: {{page.url}}\n",
-                "Info:\nMy URL: /foo.html")
-            ])
+    'contents, expected',
+    [
+        ("Raw text", "Raw text"),
+        ("This is {{foo}}", "This is bar"),
+        ("Info:\nMy URL: {{page.url}}\n",
+         "Info:\nMy URL: /foo.html")
+    ])
 def test_simple(contents, expected):
     fs = (mock_fs()
-            .withConfig(app_config)
-            .withPage('pages/foo', config=page_config, contents=contents))
+          .withConfig(app_config)
+          .withPage('pages/foo', config=page_config, contents=contents))
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
-        page = get_simple_page(app, 'foo.md')
-        route = app.getSourceRoute('pages', None)
+        page = fs.getSimplePage('foo.md')
+        route = app.getSourceRoute('pages')
         route_metadata = {'slug': 'foo'}
         output = render_simple_page(page, route, route_metadata)
         assert output == expected
@@ -39,13 +39,13 @@
     layout = "{{content}}\nFor site: {{foo}}\n"
     expected = "Blah\n\nFor site: bar"
     fs = (mock_fs()
-            .withConfig(app_config)
-            .withAsset('templates/blah.jinja', layout)
-            .withPage('pages/foo', config={'layout': 'blah'},
-                      contents=contents))
+          .withConfig(app_config)
+          .withAsset('templates/blah.jinja', layout)
+          .withPage('pages/foo', config={'layout': 'blah'},
+                    contents=contents))
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
-        page = get_simple_page(app, 'foo.md')
+        page = fs.getSimplePage('foo.md')
         route = app.getSourceRoute('pages', None)
         route_metadata = {'slug': 'foo'}
         output = render_simple_page(page, route, route_metadata)
@@ -57,12 +57,12 @@
     partial = "- URL: {{page.url}}\n- SLUG: {{page.slug}}\n"
     expected = "Info:\n- URL: /foo.html\n- SLUG: foo"
     fs = (mock_fs()
-            .withConfig(app_config)
-            .withAsset('templates/page_info.jinja', partial)
-            .withPage('pages/foo', config=page_config, contents=contents))
+          .withConfig(app_config)
+          .withAsset('templates/page_info.jinja', partial)
+          .withPage('pages/foo', config=page_config, contents=contents))
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
-        page = get_simple_page(app, 'foo.md')
+        page = fs.getSimplePage('foo.md')
         route = app.getSourceRoute('pages', None)
         route_metadata = {'slug': 'foo'}
         output = render_simple_page(page, route, route_metadata)
--- a/tests/test_templating_pystacheengine.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/test_templating_pystacheengine.py	Tue Oct 17 01:07:30 2017 -0700
@@ -1,33 +1,33 @@
 import pytest
-from .mockutil import (
-        mock_fs, mock_fs_scope, get_simple_page, render_simple_page)
+from .mockutil import mock_fs, mock_fs_scope
+from .rdrutil import render_simple_page
 
 
 app_config = {
-        'site': {
-            'default_format': 'none',
-            'default_template_engine': 'mustache'},
-        'foo': 'bar'}
+    'site': {
+        'default_format': 'none',
+        'default_template_engine': 'mustache'},
+    'foo': 'bar'}
 page_config = {'layout': 'none'}
 
 open_patches = ['pystache.common']
 
 
 @pytest.mark.parametrize(
-        'contents, expected',
-        [
-            ("Raw text", "Raw text"),
-            ("This is {{foo}}", "This is bar"),
-            ("Info:\n{{#page}}\nMy URL: {{url}}\n{{/page}}\n",
-                "Info:\nMy URL: /foo.html\n")
-            ])
+    'contents, expected',
+    [
+        ("Raw text", "Raw text"),
+        ("This is {{foo}}", "This is bar"),
+        ("Info:\n{{#page}}\nMy URL: {{url}}\n{{/page}}\n",
+         "Info:\nMy URL: /foo.html\n")
+    ])
 def test_simple(contents, expected):
     fs = (mock_fs()
-            .withConfig(app_config)
-            .withPage('pages/foo', config=page_config, contents=contents))
+          .withConfig(app_config)
+          .withPage('pages/foo', config=page_config, contents=contents))
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
-        page = get_simple_page(app, 'foo.md')
+        page = fs.getSimplePage('foo.md')
         route = app.getSourceRoute('pages', None)
         route_metadata = {'slug': 'foo'}
         output = render_simple_page(page, route, route_metadata)
@@ -39,13 +39,13 @@
     layout = "{{content}}\nFor site: {{foo}}\n"
     expected = "Blah\n\nFor site: bar\n"
     fs = (mock_fs()
-            .withConfig(app_config)
-            .withAsset('templates/blah.mustache', layout)
-            .withPage('pages/foo', config={'layout': 'blah'},
-                      contents=contents))
+          .withConfig(app_config)
+          .withAsset('templates/blah.mustache', layout)
+          .withPage('pages/foo', config={'layout': 'blah'},
+                    contents=contents))
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
-        page = get_simple_page(app, 'foo.md')
+        page = fs.getSimplePage('foo.md')
         route = app.getSourceRoute('pages', None)
         route_metadata = {'slug': 'foo'}
         output = render_simple_page(page, route, route_metadata)
@@ -59,12 +59,12 @@
     partial = "- URL: {{url}}\n- SLUG: {{slug}}\n"
     expected = "Info:\n- URL: /foo.html\n- SLUG: foo\n"
     fs = (mock_fs()
-            .withConfig(app_config)
-            .withAsset('templates/page_info.mustache', partial)
-            .withPage('pages/foo', config=page_config, contents=contents))
+          .withConfig(app_config)
+          .withAsset('templates/page_info.mustache', partial)
+          .withPage('pages/foo', config=page_config, contents=contents))
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
-        page = get_simple_page(app, 'foo.md')
+        page = fs.getSimplePage('foo.md')
         route = app.getSourceRoute('pages', None)
         route_metadata = {'slug': 'foo'}
         output = render_simple_page(page, route, route_metadata)
--- a/tests/tmpfs.py	Tue Oct 17 01:04:10 2017 -0700
+++ b/tests/tmpfs.py	Tue Oct 17 01:07:30 2017 -0700
@@ -9,9 +9,9 @@
 class TempDirFileSystem(TestFileSystemBase):
     def __init__(self):
         self._root = os.path.join(
-                os.path.dirname(__file__),
-                '__tmpfs__',
-                '%d' % random.randrange(1000))
+            os.path.dirname(__file__),
+            '__tmpfs__',
+            '%d' % random.randrange(1000))
         self._done = False
 
     def path(self, p):