[DRE-commits] [nanoc] 01/21: New upstream version 4.4.6

Cédric Boutillier boutil at moszumanska.debian.org
Thu Jan 5 14:04:55 UTC 2017


This is an automated email from the git hooks/post-receive script.

boutil pushed a commit to branch master
in repository nanoc.

commit 8f88dff73e538b4c3190deea91443073e51bf03b
Author: Cédric Boutillier <boutil at debian.org>
Date:   Wed Dec 28 22:41:20 2016 +0100

    New upstream version 4.4.6
---
 Appraisals                                         |  11 +
 ChangeLog                                          |   3 -
 Gemfile                                            |  18 +-
 Gemfile.lock                                       | 168 ++++---
 NEWS.md                                            | 123 ++++-
 README.md                                          |   2 +-
 Rakefile                                           |  21 +-
 doc/yardoc_handlers/identifier.rb                  |  30 --
 .../default/layout/html/footer.erb                 |  19 -
 lib/nanoc.rb                                       |   4 +
 lib/nanoc/base.rb                                  |  23 +-
 lib/nanoc/base/compilation/compiler.rb             | 300 -------------
 lib/nanoc/base/compilation/dependency_tracker.rb   |  43 --
 lib/nanoc/base/compilation/outdatedness_checker.rb | 210 ---------
 lib/nanoc/base/contracts_support.rb                |  60 ++-
 lib/nanoc/base/core_ext/array.rb                   |   2 -
 lib/nanoc/base/core_ext/hash.rb                    |   2 -
 lib/nanoc/base/entities.rb                         |  12 +-
 lib/nanoc/base/entities/configuration.rb           |  32 +-
 lib/nanoc/base/{ => entities}/context.rb           |  12 +-
 lib/nanoc/base/entities/dependency.rb              |  28 ++
 lib/nanoc/base/{ => entities}/directed_graph.rb    |  30 +-
 lib/nanoc/base/entities/document.rb                |  27 +-
 lib/nanoc/base/entities/identifiable_collection.rb |   3 +-
 lib/nanoc/base/entities/identifier.rb              |  14 +-
 lib/nanoc/base/entities/item_rep.rb                |  61 +--
 .../outdatedness_reasons.rb                        |  29 +-
 lib/nanoc/base/entities/outdatedness_status.rb     |  23 +
 ...{rule_memory_action.rb => processing_action.rb} |   6 +-
 lib/nanoc/base/entities/processing_actions.rb      |   3 +
 .../filter.rb                                      |   4 +-
 .../layout.rb                                      |   4 +-
 .../base/entities/processing_actions/snapshot.rb   |  28 ++
 lib/nanoc/base/entities/props.rb                   |  76 ++++
 lib/nanoc/base/entities/rule_memory.rb             |  43 +-
 lib/nanoc/base/entities/rule_memory_actions.rb     |   3 -
 .../base/entities/rule_memory_actions/snapshot.rb  |  26 --
 lib/nanoc/base/entities/site.rb                    |  22 +-
 lib/nanoc/base/entities/snapshot_def.rb            |  10 +-
 lib/nanoc/base/errors.rb                           |  24 +
 lib/nanoc/base/feature.rb                          |  79 +++-
 lib/nanoc/base/memoization.rb                      |   2 -
 lib/nanoc/base/plugin_registry.rb                  |   2 +-
 lib/nanoc/base/repos.rb                            |   1 +
 lib/nanoc/base/repos/checksum_store.rb             |  57 ++-
 lib/nanoc/base/repos/compiled_content_cache.rb     |  41 +-
 lib/nanoc/base/repos/config_loader.rb              |  14 +-
 lib/nanoc/base/repos/data_source.rb                |  26 +-
 lib/nanoc/base/repos/dependency_store.rb           |  81 ++--
 .../base/{compilation => repos}/item_rep_repo.rb   |   0
 lib/nanoc/base/repos/rule_memory_store.rb          |   4 +-
 lib/nanoc/base/repos/site_loader.rb                |   2 +-
 lib/nanoc/base/repos/store.rb                      |  41 +-
 lib/nanoc/base/services.rb                         |   9 +
 lib/nanoc/base/services/action_provider.rb         |   4 +
 lib/nanoc/base/{ => services}/checksummer.rb       |  68 ++-
 lib/nanoc/base/services/compiler.rb                | 377 ++++++++++++++++
 lib/nanoc/base/services/compiler_loader.rb         |  20 +-
 lib/nanoc/base/services/dependency_tracker.rb      |  55 +++
 lib/nanoc/base/services/executor.rb                |  77 +---
 lib/nanoc/base/{compilation => services}/filter.rb |  11 +-
 lib/nanoc/base/services/item_rep_builder.rb        |   4 +
 lib/nanoc/base/services/item_rep_router.rb         |  23 +-
 lib/nanoc/base/services/item_rep_selector.rb       |  19 +-
 lib/nanoc/base/services/item_rep_writer.rb         |   7 +-
 lib/nanoc/base/services/outdatedness_checker.rb    | 181 ++++++++
 lib/nanoc/base/services/outdatedness_rule.rb       |  21 +
 lib/nanoc/base/services/outdatedness_rules.rb      | 121 +++++
 lib/nanoc/base/services/pruner.rb                  | 110 +++++
 lib/nanoc/base/views.rb                            |   2 +
 lib/nanoc/base/views/item_rep_collection_view.rb   |  15 +-
 lib/nanoc/base/views/item_rep_view.rb              |   6 +-
 lib/nanoc/base/views/mixins/document_view_mixin.rb |   9 +-
 .../base/views/mixins/with_reps_view_mixin.rb      |   2 +-
 .../views/post_compile_item_rep_collection_view.rb |   8 +
 lib/nanoc/base/views/post_compile_item_rep_view.rb |  18 +
 lib/nanoc/base/views/post_compile_item_view.rb     |   4 +
 lib/nanoc/base/views/view_context.rb               |   6 +-
 lib/nanoc/checking.rb                              |  11 +
 lib/nanoc/{extra => }/checking/check.rb            |   8 +-
 lib/nanoc/checking/checks.rb                       |  20 +
 lib/nanoc/{extra => }/checking/checks/css.rb       |   4 +-
 .../{extra => }/checking/checks/external_links.rb  |  48 +-
 lib/nanoc/{extra => }/checking/checks/html.rb      |   4 +-
 .../{extra => }/checking/checks/internal_links.rb  |   4 +-
 .../{extra => }/checking/checks/mixed_content.rb   |   4 +-
 lib/nanoc/{extra => }/checking/checks/stale.rb     |   7 +-
 .../{extra => }/checking/checks/w3c_validator.rb   |   4 +-
 lib/nanoc/{extra => }/checking/dsl.rb              |   7 +-
 lib/nanoc/{extra => }/checking/issue.rb            |   2 +-
 lib/nanoc/{extra => }/checking/runner.rb           |   8 +-
 lib/nanoc/cli.rb                                   |   4 -
 lib/nanoc/cli/cleaning_stream.rb                   |   9 +-
 lib/nanoc/cli/command_runner.rb                    |  13 +-
 lib/nanoc/cli/commands/check.rb                    |   2 +-
 lib/nanoc/cli/commands/compile.rb                  | 146 +++---
 lib/nanoc/cli/commands/create-site.rb              |   3 +-
 lib/nanoc/cli/commands/deploy.rb                   |   8 +-
 lib/nanoc/cli/commands/nanoc.rb                    |   4 +
 lib/nanoc/cli/commands/prune.rb                    |   4 +-
 lib/nanoc/cli/commands/shell.rb                    |  25 +-
 lib/nanoc/cli/commands/show-data.rb                |  29 +-
 lib/nanoc/cli/commands/show-plugins.rb             |   8 +-
 lib/nanoc/cli/commands/show-rules.rb               |   5 +-
 lib/nanoc/cli/commands/view.rb                     |   4 +-
 lib/nanoc/cli/error_handler.rb                     |  44 +-
 lib/nanoc/data_sources/filesystem.rb               |  61 ++-
 lib/nanoc/data_sources/filesystem/errors.rb        |  55 +++
 .../filesystem/tools.rb}                           |  35 +-
 lib/nanoc/deploying.rb                             |   8 +
 lib/nanoc/{extra => deploying}/deployer.rb         |   4 +-
 lib/nanoc/deploying/deployers.rb                   |  10 +
 lib/nanoc/{extra => deploying}/deployers/fog.rb    |   6 +-
 lib/nanoc/{extra => deploying}/deployers/rsync.rb  |   6 +-
 lib/nanoc/extra.rb                                 |  18 +-
 lib/nanoc/extra/checking.rb                        |  11 -
 lib/nanoc/extra/checking/checks.rb                 |  20 -
 lib/nanoc/extra/core_ext/time.rb                   |   2 +-
 lib/nanoc/extra/deployers.rb                       |  10 -
 lib/nanoc/extra/parallel_collection.rb             |  57 +++
 lib/nanoc/extra/pruner.rb                          |  87 ----
 lib/nanoc/filters/asciidoc.rb                      |   2 -
 lib/nanoc/filters/coffeescript.rb                  |   2 -
 lib/nanoc/filters/colorize_syntax.rb               |  21 +-
 lib/nanoc/filters/handlebars.rb                    |   2 -
 lib/nanoc/filters/kramdown.rb                      |  16 +-
 lib/nanoc/filters/less.rb                          |  68 ++-
 lib/nanoc/filters/mustache.rb                      |   4 +-
 lib/nanoc/filters/rdoc.rb                          |   5 -
 lib/nanoc/filters/redcarpet.rb                     |   4 -
 lib/nanoc/filters/relativize_paths.rb              |   2 +-
 lib/nanoc/filters/slim.rb                          |   2 -
 lib/nanoc/filters/typogruby.rb                     |   2 -
 lib/nanoc/filters/xsl.rb                           |   2 -
 lib/nanoc/filters/yui_compressor.rb                |   2 -
 lib/nanoc/helpers/blogging.rb                      |   8 +-
 lib/nanoc/helpers/breadcrumbs.rb                   |  36 +-
 lib/nanoc/helpers/capturing.rb                     |  46 +-
 lib/nanoc/helpers/link_to.rb                       |  10 +-
 lib/nanoc/helpers/rendering.rb                     |  38 +-
 lib/nanoc/rule_dsl/action_provider.rb              |   4 +-
 lib/nanoc/rule_dsl/compiler_dsl.rb                 |  12 +-
 lib/nanoc/rule_dsl/recording_executor.rb           |  44 +-
 lib/nanoc/rule_dsl/rule.rb                         |   2 -
 lib/nanoc/rule_dsl/rule_context.rb                 |   6 +-
 lib/nanoc/rule_dsl/rule_memory_calculator.rb       |  66 ++-
 lib/nanoc/spec.rb                                  |  48 +-
 lib/nanoc/version.rb                               |   2 +-
 nanoc.gemspec                                      |   5 +-
 spec/contributors_spec.rb                          |  18 +
 spec/nanoc/base/checksummer_spec.rb                | 381 ++++++++++++++++
 spec/nanoc/base/compiler_spec.rb                   | 181 ++++++++
 spec/nanoc/base/entities/configuration_spec.rb     |  49 ++
 spec/nanoc/base/entities/content_spec.rb           | 193 ++++++++
 spec/nanoc/base/entities/document_spec.rb          | 206 +++++++++
 spec/nanoc/base/entities/identifier_spec.rb        | 460 +++++++++++++++++++
 spec/nanoc/base/entities/item_rep_spec.rb          | 226 ++++++++++
 spec/nanoc/base/entities/item_spec.rb              |   3 +
 spec/nanoc/base/entities/layout_spec.rb            |   3 +
 spec/nanoc/base/entities/lazy_value_spec.rb        | 106 +++++
 .../base/entities/outdatedness_status_spec.rb      | 113 +++++
 spec/nanoc/base/entities/pattern_spec.rb           | 125 ++++++
 spec/nanoc/base/entities/processing_action_spec.rb |   9 +
 .../entities/processing_actions/filter_spec.rb     |  18 +
 .../entities/processing_actions/layout_spec.rb     |  18 +
 .../entities/processing_actions/snapshot_spec.rb   |  32 ++
 spec/nanoc/base/entities/props_spec.rb             | 195 ++++++++
 spec/nanoc/base/entities/rule_memory_spec.rb       | 131 ++++++
 spec/nanoc/base/entities/site_spec.rb              |  73 +++
 spec/nanoc/base/feature_spec.rb                    | 107 +++++
 spec/nanoc/base/filter_spec.rb                     |  99 +++++
 spec/nanoc/base/item_rep_writer_spec.rb            | 131 ++++++
 spec/nanoc/base/plugin_registry_spec.rb            |  29 ++
 spec/nanoc/base/repos/checksum_store_spec.rb       | 133 ++++++
 .../base/repos/compiled_content_cache_spec.rb      |  55 +++
 spec/nanoc/base/repos/config_loader_spec.rb        | 243 ++++++++++
 spec/nanoc/base/repos/dependency_store_spec.rb     | 195 ++++++++
 spec/nanoc/base/repos/site_loader_spec.rb          | 214 +++++++++
 .../nanoc/base/services/dependency_tracker_spec.rb | 238 ++++++++++
 spec/nanoc/base/services/executor_spec.rb          | 495 +++++++++++++++++++++
 spec/nanoc/base/services/item_rep_router_spec.rb   | 134 ++++++
 spec/nanoc/base/services/item_rep_selector_spec.rb | 169 +++++++
 .../base/services/outdatedness_checker_spec.rb     | 370 +++++++++++++++
 .../nanoc/base/services/outdatedness_rules_spec.rb | 432 ++++++++++++++++++
 spec/nanoc/base/services/pruner_spec.rb            | 105 +++++
 .../base/services/temp_filename_factory_spec.rb    |  87 ++++
 spec/nanoc/base/views/config_view_spec.rb          |  96 ++++
 spec/nanoc/base/views/document_view_spec.rb        | 332 ++++++++++++++
 .../views/identifiable_collection_view_spec.rb     | 190 ++++++++
 .../views/item_collection_with_reps_view_spec.rb   |  18 +
 .../item_collection_without_reps_view_spec.rb      |  18 +
 .../base/views/item_rep_collection_view_spec.rb    | 143 ++++++
 spec/nanoc/base/views/item_rep_view_spec.rb        | 265 +++++++++++
 spec/nanoc/base/views/item_view_spec.rb            | 341 ++++++++++++++
 .../base/views/layout_collection_view_spec.rb      |  18 +
 spec/nanoc/base/views/layout_view_spec.rb          |  14 +
 spec/nanoc/base/views/mutable_config_view_spec.rb  |  16 +
 .../nanoc/base/views/mutable_document_view_spec.rb |  92 ++++
 .../mutable_identifiable_collection_view_spec.rb   |  36 ++
 .../views/mutable_item_collection_view_spec.rb     |  49 ++
 spec/nanoc/base/views/mutable_item_view_spec.rb    |  22 +
 .../views/mutable_layout_collection_view_spec.rb   |  49 ++
 spec/nanoc/base/views/mutable_layout_view_spec.rb  |  13 +
 .../post_compile_item_rep_collection_view_spec.rb  |   4 +
 .../base/views/post_compile_item_rep_view_spec.rb  | 137 ++++++
 .../base/views/post_compile_item_view_spec.rb      |  56 +++
 .../commands/compile/file_action_printer_spec.rb   |  76 ++++
 .../cli/commands/compile/timing_recorder_spec.rb   |  66 +++
 spec/nanoc/cli/commands/compile_spec.rb            |  64 +++
 spec/nanoc/cli/commands/deploy_spec.rb             | 327 ++++++++++++++
 spec/nanoc/cli/commands/shell_spec.rb              |  54 +++
 spec/nanoc/cli/commands/show_data_spec.rb          | 126 ++++++
 spec/nanoc/cli/commands/show_rules_spec.rb         | 112 +++++
 spec/nanoc/cli/commands/view_spec.rb               |  58 +++
 spec/nanoc/data_sources/filesystem_spec.rb         |  56 +++
 spec/nanoc/deploying/fog_spec.rb                   | 193 ++++++++
 spec/nanoc/extra/parallel_collection_spec.rb       | 108 +++++
 spec/nanoc/filters/colorize_syntax/rouge_spec.rb   | 195 ++++++++
 spec/nanoc/filters/less_spec.rb                    | 120 +++++
 spec/nanoc/helpers/blogging_spec.rb                | 216 +++++++++
 spec/nanoc/helpers/breadcrumbs_spec.rb             | 133 ++++++
 spec/nanoc/helpers/capturing_spec.rb               | 181 ++++++++
 spec/nanoc/helpers/child_parent_spec.rb            | 105 +++++
 spec/nanoc/helpers/filtering_spec.rb               |  72 +++
 spec/nanoc/helpers/html_escape_spec.rb             |  35 ++
 spec/nanoc/helpers/link_to_spec.rb                 | 275 ++++++++++++
 spec/nanoc/helpers/rendering_spec.rb               | 141 ++++++
 spec/nanoc/helpers/tagging_spec.rb                 | 104 +++++
 spec/nanoc/helpers/text_spec.rb                    |  58 +++
 .../integration/outdatedness_integration_spec.rb   | 208 +++++++++
 spec/nanoc/regressions/gh_1015_spec.rb             |  17 +
 spec/nanoc/regressions/gh_1031_spec.rb             |  54 +++
 spec/nanoc/regressions/gh_1035_spec.rb             |  33 ++
 spec/nanoc/regressions/gh_1040_spec.rb             |  22 +
 spec/nanoc/regressions/gh_761_spec.rb              |  23 +
 spec/nanoc/regressions/gh_767_spec.rb              |  19 +
 spec/nanoc/regressions/gh_769_spec.rb              |  30 ++
 spec/nanoc/regressions/gh_776_spec.rb              |  43 ++
 spec/nanoc/regressions/gh_787_spec.rb              |  19 +
 spec/nanoc/regressions/gh_795_spec.rb              |  19 +
 spec/nanoc/regressions/gh_804_spec.rb              |  26 ++
 spec/nanoc/regressions/gh_807_spec.rb              |  17 +
 spec/nanoc/regressions/gh_809_spec.rb              |  17 +
 spec/nanoc/regressions/gh_813_spec.rb              |  22 +
 spec/nanoc/regressions/gh_815_spec.rb              |  18 +
 spec/nanoc/regressions/gh_828_spec.rb              |  23 +
 spec/nanoc/regressions/gh_833_spec.rb              |  14 +
 spec/nanoc/regressions/gh_841_spec.rb              |  15 +
 spec/nanoc/regressions/gh_867_spec.rb              |  15 +
 spec/nanoc/regressions/gh_882_spec.rb              |  29 ++
 spec/nanoc/regressions/gh_885_spec.rb              |  30 ++
 spec/nanoc/regressions/gh_891_spec.rb              |  26 ++
 spec/nanoc/regressions/gh_913_spec.rb              |  24 +
 spec/nanoc/regressions/gh_928_spec.rb              |   5 +
 spec/nanoc/regressions/gh_937_spec.rb              |  25 ++
 spec/nanoc/regressions/gh_942_spec.rb              |  21 +
 spec/nanoc/regressions/gh_947_spec.rb              |  21 +
 spec/nanoc/regressions/gh_948_spec.rb              |  16 +
 spec/nanoc/regressions/gh_951_spec.rb              |  19 +
 spec/nanoc/regressions/gh_954_spec.rb              |  33 ++
 spec/nanoc/regressions/gh_970a_spec.rb             |  17 +
 spec/nanoc/regressions/gh_970b_spec.rb             |  50 +++
 spec/nanoc/regressions/gh_974_spec.rb              |  17 +
 spec/nanoc/regressions/gh_981_spec.rb              |  21 +
 spec/nanoc/rule_dsl/recording_executor_spec.rb     | 142 ++++++
 spec/nanoc/rule_dsl/rule_context_spec.rb           | 177 ++++++++
 spec/nanoc/rule_dsl/rule_memory_calculator_spec.rb | 233 ++++++++++
 spec/nanoc/rule_dsl/rules_collection_spec.rb       | 299 +++++++++++++
 spec/regression_filenames_spec.rb                  |  16 +
 spec/spec_helper.rb                                | 173 +++++++
 tasks/doc.rake                                     |  16 -
 tasks/rubocop.rake                                 |   6 -
 tasks/test.rake                                    |  25 --
 test/base/core_ext/array_spec.rb                   |   2 +
 test/base/core_ext/hash_spec.rb                    |   2 +
 test/base/core_ext/string_spec.rb                  |   2 +
 test/base/temp_filename_factory_spec.rb            |  66 ---
 test/base/test_checksum_store.rb                   |  28 --
 test/base/test_code_snippet.rb                     |   2 +
 test/base/test_compiler.rb                         |  23 +-
 test/base/test_context.rb                          |  14 +-
 test/base/test_data_source.rb                      |  24 +
 test/base/test_dependency_tracker.rb               | 109 ++---
 test/base/test_directed_graph.rb                   |  51 ++-
 test/base/test_filter.rb                           |  12 +-
 test/base/test_item.rb                             |   2 +
 test/base/test_item_array.rb                       |   4 +-
 test/base/test_item_rep.rb                         | 153 -------
 test/base/test_layout.rb                           |   2 +
 test/base/test_memoization.rb                      |   2 +
 test/base/test_notification_center.rb              |   2 +
 test/base/test_outdatedness_checker.rb             |  37 +-
 test/base/test_plugin.rb                           |   2 +
 test/base/test_site.rb                             |   2 +
 test/base/test_store.rb                            |  24 +
 test/{extra => }/checking/checks/test_css.rb       |  10 +-
 .../checking/checks/test_external_links.rb         |  22 +-
 test/{extra => }/checking/checks/test_html.rb      |  14 +-
 .../checking/checks/test_internal_links.rb         |  26 +-
 .../checking/checks/test_mixed_content.rb          |  20 +-
 test/{extra => }/checking/checks/test_stale.rb     |   8 +-
 test/{extra => }/checking/test_check.rb            |  10 +-
 test/{extra => }/checking/test_dsl.rb              |  18 +-
 test/{extra => }/checking/test_runner.rb           |  10 +-
 test/cli/commands/test_check.rb                    |   2 +
 test/cli/commands/test_compile.rb                  |   2 +
 test/cli/commands/test_create_site.rb              |   4 +-
 test/cli/commands/test_help.rb                     |   2 +
 test/cli/commands/test_info.rb                     |   2 +
 test/cli/commands/test_prune.rb                    |   2 +
 test/cli/test_cleaning_stream.rb                   |  17 +-
 test/cli/test_cli.rb                               |  20 +-
 test/cli/test_error_handler.rb                     |  39 +-
 test/cli/test_logger.rb                            |   5 +-
 test/data_sources/test_filesystem.rb               |  42 +-
 .../test_filesystem_tools.rb                       |  28 +-
 test/{extra/deployers => deploying}/test_fog.rb    |  18 +-
 test/{extra/deployers => deploying}/test_rsync.rb  |  20 +-
 test/extra/core_ext/test_pathname.rb               |   2 +
 test/extra/core_ext/test_time.rb                   |   8 +-
 test/extra/test_link_collector.rb                  |   2 +
 test/extra/test_piper.rb                           |   2 +
 .../test_coderay.rb}                               | 180 +-------
 test/filters/colorize_syntax/test_common.rb        |  83 ++++
 test/filters/colorize_syntax/test_pygmentize.rb    |  37 ++
 test/filters/colorize_syntax/test_pygments.rb      |  19 +
 test/filters/colorize_syntax/test_simon.rb         |  22 +
 test/filters/test_asciidoc.rb                      |   2 +
 test/filters/test_bluecloth.rb                     |   2 +
 test/filters/test_coffeescript.rb                  |   4 +
 test/filters/test_erb.rb                           |  12 +-
 test/filters/test_erubis.rb                        |  10 +-
 test/filters/test_haml.rb                          |  12 +-
 test/filters/test_handlebars.rb                    |   6 +
 test/filters/test_kramdown.rb                      |  32 +-
 test/filters/test_less.rb                          | 126 ------
 test/filters/test_markaby.rb                       |   2 +
 test/filters/test_maruku.rb                        |   2 +
 test/filters/test_mustache.rb                      |   6 +-
 test/filters/test_pandoc.rb                        |   2 +
 test/filters/test_rainpress.rb                     |   2 +
 test/filters/test_rdiscount.rb                     |   2 +
 test/filters/test_rdoc.rb                          |   2 +
 test/filters/test_redcarpet.rb                     |   2 +
 test/filters/test_redcloth.rb                      |   2 +
 test/filters/test_relativize_paths.rb              |  28 ++
 test/filters/test_rubypants.rb                     |   2 +
 test/filters/test_sass.rb                          |   6 +-
 test/filters/test_slim.rb                          |   6 +-
 test/filters/test_typogruby.rb                     |   2 +
 test/filters/test_uglify_js.rb                     |   6 +
 test/filters/test_xsl.rb                           |  46 +-
 test/filters/test_yui_compressor.rb                |   8 +-
 test/fixtures/vcr_cassettes/css_run_error.yml      |  23 +-
 test/fixtures/vcr_cassettes/css_run_ok.yml         |  25 +-
 .../fixtures/vcr_cassettes/css_run_parse_error.yml |  25 +-
 test/fixtures/vcr_cassettes/html_run_error.yml     |  52 ++-
 test/fixtures/vcr_cassettes/html_run_ok.yml        | 116 ++++-
 test/helper.rb                                     |   6 +
 test/helpers/test_blogging.rb                      |  64 +++
 test/helpers/test_capturing.rb                     |   9 +-
 test/helpers/test_link_to.rb                       |   2 +
 test/helpers/test_xml_sitemap.rb                   |  10 +-
 test/rule_dsl/test_action_provider.rb              |   2 +
 test/rule_dsl/test_compiler_dsl.rb                 |  32 +-
 test/rule_dsl/test_rule.rb                         |   2 +
 test/rule_dsl/test_rules_collection.rb             |   2 +
 test/test_gem.rb                                   |   2 +
 368 files changed, 16341 insertions(+), 2655 deletions(-)

diff --git a/Appraisals b/Appraisals
new file mode 100644
index 0000000..94a8ece
--- /dev/null
+++ b/Appraisals
@@ -0,0 +1,11 @@
+appraise 'rouge-1' do
+  group :plugins do
+    gem 'rouge', '~> 1.0'
+  end
+end
+
+appraise 'rouge-2' do
+  group :plugins do
+    gem 'rouge', '~> 2.0'
+  end
+end
\ No newline at end of file
diff --git a/ChangeLog b/ChangeLog
deleted file mode 100644
index 706bed1..0000000
--- a/ChangeLog
+++ /dev/null
@@ -1,3 +0,0 @@
-For a list of all changes, please see the changelog on the project repository
-instead (https://github.com/nanoc/nanoc). For release notes, please see the
-NEWS file.
diff --git a/Gemfile b/Gemfile
index 3a93341..2c9a378 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,25 +2,29 @@ source 'https://rubygems.org'
 
 gemspec
 
-gem 'json', '~> 2.0'
-
 group :devel do
   gem 'contracts', '~> 0.14'
   gem 'coveralls', require: false
-  gem 'guard-rake'
   gem 'fuubar'
+  gem 'guard-rake'
+  gem 'json', '~> 2.0'
+  gem 'm', '~> 1.5'
   gem 'minitest', '~> 5.0'
   gem 'mocha'
   gem 'pry'
+  gem 'rainbow', '~> 2.1'
   gem 'rake'
-  gem 'rdoc'
+  gem 'rdoc', '~> 5.0'
   gem 'rspec'
+  gem 'rspec-its', '~> 1.2'
   gem 'rspec-mocks'
-  gem 'rubocop'
+  gem 'rubocop', github: 'bbatsov/rubocop'
   gem 'simplecov', require: false
+  gem 'timecop'
   gem 'vcr'
   gem 'webmock'
   gem 'yard'
+  gem 'yard-contracts'
 end
 
 group :plugins do
@@ -43,7 +47,8 @@ group :plugins do
   gem 'mustache', '~> 1.0'
   gem 'nokogiri', '~> 1.6'
   gem 'pandoc-ruby'
-  gem 'pygments.rb', platforms: [:ruby, :mswin]
+  gem 'parallel'
+  gem 'pygments.rb', github: 'tmm1/pygments.rb', platforms: [:ruby, :mswin]
   gem 'rack'
   gem 'rainpress'
   gem 'rdiscount', '~> 2.2', platforms: [:ruby, :mswin]
@@ -53,6 +58,7 @@ group :plugins do
   gem 'rubypants'
   gem 'sass'
   gem 'slim'
+  gem 'therubyracer', github: 'cowboyd/therubyracer'
   gem 'typogruby'
   gem 'uglifier'
   gem 'w3c_validators'
diff --git a/Gemfile.lock b/Gemfile.lock
index 4f55ad7..f6fcbe7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,33 @@
+GIT
+  remote: git://github.com/bbatsov/rubocop.git
+  revision: 186b16e10514eaf304990d614184b980abc0ad2b
+  specs:
+    rubocop (0.46.0)
+      parser (>= 2.3.3.1, < 3.0)
+      powerpack (~> 0.1)
+      rainbow (>= 1.99.1, < 3.0)
+      ruby-progressbar (~> 1.7)
+      unicode-display_width (~> 1.0, >= 1.0.1)
+
+GIT
+  remote: git://github.com/cowboyd/therubyracer.git
+  revision: 2f1127bed45f03b91f3c92469be26399d3c1bcd8
+  specs:
+    therubyracer (0.12.2)
+      libv8 (~> 3.16.14.15)
+      ref
+
+GIT
+  remote: git://github.com/tmm1/pygments.rb.git
+  revision: a988d127d3270d21685d2ac2e586060bf43709f3
+  specs:
+    pygments.rb (1.1.0)
+      multi_json (>= 1.0.0)
+
 PATH
   remote: .
   specs:
-    nanoc (4.3.4)
+    nanoc (4.4.6)
       cri (~> 2.3)
       hamster (~> 3.0)
       ref (~> 2.0)
@@ -9,20 +35,25 @@ PATH
 GEM
   remote: https://rubygems.org/
   specs:
-    CFPropertyList (2.3.3)
+    CFPropertyList (2.3.4)
     RedCloth (4.3.2)
-    addressable (2.4.0)
+    addressable (2.5.0)
+      public_suffix (~> 2.0, >= 2.0.2)
     adsf (1.2.1)
       rack (>= 1.0.0)
+    appraisal (2.1.0)
+      bundler
+      rake
+      thor (>= 0.14.0)
     ast (2.3.0)
     bluecloth (2.2.0)
     builder (3.2.2)
-    chunky_png (1.3.7)
+    chunky_png (1.3.8)
     coderay (1.1.1)
     coffee-script (2.4.1)
       coffee-script-source
       execjs
-    coffee-script-source (1.10.0)
+    coffee-script-source (1.12.2)
     colored (1.2)
     commonjs (0.2.7)
     compass (1.0.3)
@@ -37,22 +68,22 @@ GEM
       sass (>= 3.3.0, < 3.5)
     compass-import-once (1.0.5)
       sass (>= 3.2, < 3.5)
-    concurrent-ruby (1.0.2)
+    concurrent-ruby (1.0.4)
     contracts (0.14.0)
-    coveralls (0.8.15)
+    coveralls (0.8.17)
       json (>= 1.8, < 3)
       simplecov (~> 0.12.0)
       term-ansicolor (~> 1.3)
       thor (~> 0.19.1)
-      tins (>= 1.6.0, < 2)
+      tins (~> 1.6)
     crack (0.4.3)
       safe_yaml (~> 1.0.0)
-    cri (2.7.0)
+    cri (2.7.1)
       colored (~> 1.2)
     diff-lcs (1.2.5)
     docile (1.1.5)
     erubis (2.7.0)
-    excon (0.53.0)
+    excon (0.54.0)
     execjs (2.7.0)
     ffi (1.9.14)
     fission (0.5.0)
@@ -94,7 +125,7 @@ GEM
     fog-atmos (0.1.0)
       fog-core
       fog-xml
-    fog-aws (0.12.0)
+    fog-aws (1.1.0)
       fog-core (~> 1.38)
       fog-json (~> 1.0)
       fog-xml (~> 0.1)
@@ -126,9 +157,9 @@ GEM
     fog-json (1.0.2)
       fog-core (~> 1.0)
       multi_json (~> 1.10)
-    fog-local (0.3.0)
+    fog-local (0.3.1)
       fog-core (~> 1.27)
-    fog-openstack (0.1.12)
+    fog-openstack (0.1.18)
       fog-core (>= 1.40)
       fog-json (>= 1.0)
       ipaddress (>= 0.8)
@@ -136,10 +167,10 @@ GEM
       fog-core (~> 1.27)
       fog-json (~> 1.0)
       fog-xml (~> 0.1)
-    fog-profitbricks (2.0.1)
+    fog-profitbricks (3.0.0)
       fog-core (~> 1.42)
       fog-json (~> 1.0)
-    fog-rackspace (0.1.1)
+    fog-rackspace (0.1.2)
       fog-core (>= 1.35)
       fog-json (>= 1.0)
       fog-xml (>= 0.1)
@@ -173,9 +204,9 @@ GEM
     fog-voxel (0.1.0)
       fog-core
       fog-xml
-    fog-vsphere (1.2.0)
+    fog-vsphere (1.5.2)
       fog-core
-      rbvmomi (~> 1.8.0)
+      rbvmomi (~> 1.9)
     fog-xenserver (0.2.3)
       fog-core
       fog-xml
@@ -205,20 +236,23 @@ GEM
     handlebars (0.8.0)
       handlebars-source (~> 4.0.5)
       therubyracer (~> 0.12.1)
-    handlebars-source (4.0.5)
-    hashdiff (0.3.0)
+    handlebars-source (4.0.6)
+    hashdiff (0.3.2)
     inflecto (0.0.2)
     ipaddress (0.8.3)
     json (2.0.2)
-    kramdown (1.12.0)
+    kramdown (1.13.1)
     less (2.6.0)
       commonjs (~> 0.2.7)
-    libv8 (3.16.14.15)
+    libv8 (3.16.14.17)
     listen (3.1.5)
       rb-fsevent (~> 0.9, >= 0.9.4)
       rb-inotify (~> 0.9, >= 0.9.7)
       ruby_dep (~> 1.2)
     lumberjack (1.0.10)
+    m (1.5.0)
+      method_source (>= 0.6.7)
+      rake (>= 0.9.2.2)
     markaby (0.8.0)
       builder
     maruku (0.7.2)
@@ -228,47 +262,44 @@ GEM
       mime-types-data (~> 3.2015)
     mime-types-data (3.2016.0521)
     mini_portile2 (2.1.0)
-    minitest (5.9.1)
-    mocha (1.1.0)
+    minitest (5.10.1)
+    mocha (1.2.1)
       metaclass (~> 0.0.1)
     multi_json (1.12.1)
     mustache (1.0.3)
     nenv (0.3.0)
-    nokogiri (1.6.8)
+    nokogiri (1.7.0)
       mini_portile2 (~> 2.1.0)
-      pkg-config (~> 1.1.7)
     notiffany (0.1.1)
       nenv (~> 0.1)
       shellany (~> 0.0)
     pandoc-ruby (2.0.1)
-    parser (2.3.1.4)
+    parallel (1.10.0)
+    parser (2.3.3.1)
       ast (~> 2.2)
-    pkg-config (1.1.7)
-    posix-spawn (0.3.11)
     powerpack (0.1.1)
     pry (0.10.4)
       coderay (~> 1.1.0)
       method_source (~> 0.8.1)
       slop (~> 3.4)
-    pygments.rb (0.6.3)
-      posix-spawn (~> 0.3.6)
-      yajl-ruby (~> 1.2.0)
+    public_suffix (2.0.4)
     rack (2.0.1)
-    rainbow (2.1.0)
+    rainbow (2.2.0)
     rainpress (1.0)
-    rake (11.3.0)
-    rb-fsevent (0.9.7)
+    rake (12.0.0)
+    rb-fsevent (0.9.8)
     rb-inotify (0.9.7)
       ffi (>= 0.5.0)
-    rbvmomi (1.8.2)
-      builder
-      nokogiri (>= 1.4.1)
-      trollop
+    rbvmomi (1.9.4)
+      builder (~> 3.2)
+      json (>= 1.8)
+      nokogiri (~> 1.5)
+      trollop (~> 2.1)
     rdiscount (2.2.0.1)
-    rdoc (4.2.1)
-    redcarpet (3.3.4)
+    rdoc (5.0.0)
+    redcarpet (3.4.0)
     ref (2.0.0)
-    rouge (2.0.6)
+    rouge (2.0.7)
     rspec (3.5.0)
       rspec-core (~> 3.5.0)
       rspec-expectations (~> 3.5.0)
@@ -278,21 +309,18 @@ GEM
     rspec-expectations (3.5.0)
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.5.0)
+    rspec-its (1.2.0)
+      rspec-core (>= 3.0.0)
+      rspec-expectations (>= 3.0.0)
     rspec-mocks (3.5.0)
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.5.0)
     rspec-support (3.5.0)
-    rubocop (0.43.0)
-      parser (>= 2.3.1.1, < 3.0)
-      powerpack (~> 0.1)
-      rainbow (>= 1.99.1, < 3.0)
-      ruby-progressbar (~> 1.7)
-      unicode-display_width (~> 1.0, >= 1.0.1)
     ruby-progressbar (1.8.1)
-    ruby_dep (1.4.0)
-    rubypants (0.5.1)
+    ruby_dep (1.5.0)
+    rubypants (0.6.0)
     safe_yaml (1.0.4)
-    sass (3.4.22)
+    sass (3.4.23)
     shellany (0.0.1)
     simplecov (0.12.0)
       docile (~> 1.1.0)
@@ -306,29 +334,29 @@ GEM
     temple (0.7.7)
     term-ansicolor (1.4.0)
       tins (~> 1.0)
-    therubyracer (0.12.2)
-      libv8 (~> 3.16.14.0)
-      ref
-    thor (0.19.1)
+    thor (0.19.4)
     tilt (2.0.5)
-    tins (1.12.0)
+    timecop (0.8.1)
+    tins (1.13.0)
     trollop (2.1.2)
     typogruby (1.0.18)
       rubypants
-    uglifier (3.0.2)
+    uglifier (3.0.4)
       execjs (>= 0.3.0, < 3)
-    unicode-display_width (1.1.1)
+    unicode-display_width (1.1.2)
     vcr (3.0.3)
-    w3c_validators (1.2)
-      json
-      nokogiri
-    webmock (2.1.0)
+    w3c_validators (1.3.1)
+      json (~> 2.0)
+      nokogiri (~> 1.6)
+    webmock (2.3.1)
       addressable (>= 2.3.6)
       crack (>= 0.3.2)
       hashdiff
     xml-simple (1.1.5)
-    yajl-ruby (1.2.1)
     yard (0.9.5)
+    yard-contracts (0.1.5)
+      contracts (~> 0.7)
+      yard (~> 0.8)
     yuicompressor (1.3.3)
 
 PLATFORMS
@@ -337,6 +365,7 @@ PLATFORMS
 DEPENDENCIES
   RedCloth
   adsf
+  appraisal (~> 2.1)
   bluecloth
   builder
   bundler (>= 1.7.10, < 2.0)
@@ -355,6 +384,7 @@ DEPENDENCIES
   kramdown
   less (~> 2.0)
   listen
+  m (~> 1.5)
   markaby
   maruku
   mime-types
@@ -364,29 +394,35 @@ DEPENDENCIES
   nanoc!
   nokogiri (~> 1.6)
   pandoc-ruby
+  parallel
   pry
-  pygments.rb
+  pygments.rb!
   rack
+  rainbow (~> 2.1)
   rainpress
   rake
   rdiscount (~> 2.2)
-  rdoc
+  rdoc (~> 5.0)
   redcarpet
   rouge
   rspec
+  rspec-its (~> 1.2)
   rspec-mocks
-  rubocop
+  rubocop!
   rubypants
   sass
   simplecov
   slim
+  therubyracer!
+  timecop
   typogruby
   uglifier
   vcr
   w3c_validators
   webmock
   yard
+  yard-contracts
   yuicompressor
 
 BUNDLED WITH
-   1.13.1
+   1.13.7
diff --git a/NEWS.md b/NEWS.md
index 129b9ad..a608770 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,120 @@
 # Nanoc news
 
+## 4.4.6 (2016-12-28)
+
+Fixes:
+
+* Fixed issue where `#compiled_content` would not return the correct content (#1040, #1041)
+
+## 4.4.5 (2016-12-24)
+
+Fixes:
+
+* Prevented stale data from making it into the checksum store and thereby blowing up in memory (#1004, #1027)
+* Fixed slow recompile after adding many items to a site (#1028)
+* Fixed wrong capturing helper output when the output field separator (`$,`) is set
+* Fixed issue that could cause items with multiple reps not to be recompiled when needed (#1031, #1032)
+* Fixed error when fetching textual content of item whose `:last` snapshot is binary (#1035, #1036)
+
+## 4.4.4 (2016-12-19)
+
+Enhancements:
+
+* Improved speed of incremental compilations (#1017, #1019, #1024)
+
+## 4.4.3 (2016-12-17)
+
+Fixes:
+
+* Prevented stale data from making it into the compiled content cache and thereby blowing up in memory (#1004, #1013)
+* Fixed “about” and “IRC channel” links in default site
+* Fixed accuracy of `<updated>` in Atom feed (use most recent `updated_at` or `created_at`) (#1007, #1014)
+
+Enhancements:
+
+* Added support for non-legacy identifiers in `#breadcrumbs_trail` (#1010, #1011)
+* Defined checksum for `Nanoc::Int::Context` to make outdatedness checker more precise (#1008, #1012)
+* Made Nanoc raise an error when item reps are routed to a path that does not start with a slash (#1015, #1016)
+
+## 4.4.2 (2016-11-27)
+
+Fixes:
+
+* Fixed “Maximum call stack size exceeded” issue in the `less` filter (#1001)
+* Fixed issue that could cause the `less` filter to not generate all necessary dependencies (#1003)
+
+Enhancements:
+
+* Improved the way that the crash log displays the item rep that is being compiled (#1000)
+
+## 4.4.1 (2016-11-21)
+
+Fixes:
+
+* Fixed an issue where the `xsl` filter would not generate a correct dependency on the layout (#996)
+
+Enhancements:
+
+* Made `view` command use index filenames specified in the `index_filenames` site configuration attribute (#998)
+
+## 4.4.0 (2016-11-19)
+
+Features:
+
+* Added support for Nanoc environments (#859)
+
+## 4.3.8 (2016-11-18)
+
+Enhancements:
+
+* Improved support for Rouge 1.x and 2.x (#880) [Rémi Barraquand]
+* Added `#include` to the `nanoc shell` command (#973)
+* Improved speed of full and incremental compilations (#977, #985)
+
+Fixes:
+
+* Made routing rules and `#write` calls accept an identifier, and not just a string (#976)
+* Removed GC speed-up hacks, which became counterproductive in Ruby 2.2 (#975)
+* Fixed issue which caused items to be always recompiled if `rep`/`item_rep` or `self` are used in those items’ rules (#982)
+
+## 4.3.7 (2016-10-29)
+
+Fixes:
+
+* Fixed issue with `show-data` and `show-rules` commands not showing all data (#970) [Chris Chapman]
+
+Enhancements:
+
+* Improved speed of `compile` command (#968)
+* Improved speed of `prune` command (#969)
+* Made kramdown warnings include affected item rep (#967) [Gregory Pakosz]
+* Made kramdown warnings configurable (#967) [Gregory Pakosz]
+
+## 4.3.6 (2016-10-23)
+
+Fixes:
+
+* Made legacy patterns properly support full identifiers (#957)
+* Fixed timezone issues in `#to_iso8601_date` (#961)
+* Fixed error when accessing item (rep) paths in shell command (#963)
+* Fixed issue that caused `#path` to be nil inside compilation rules (#964)
+* Made `__FILE__` in Checks file be a absolute path (#966)
+
+Enhancements:
+
+* Made the command line write status information to stderr, not stdout (#958)
+
+## 4.3.5 (2016-10-14)
+
+Fixes:
+
+* Handle `form/@action` in `relativize_paths` filter (#950) [Lorin Werthen]
+
+Experimental features:
+
+* `profiler`: adds `--profile` option to the `compile` command to profile compilation (#903)
+* `environments`: adds support for Nanoc environments (#859)
+
 ## 4.3.4 (2016-10-02)
 
 Fixes:
@@ -635,7 +750,7 @@ Fixes:
 
 * Updated references to old web site and old repository
 * Made `require` errors mention Bundler if appropriate
-* Fixed bug which caused pruner not to delete directories in some cases [@reima]
+* Fixed bug which caused pruner not to delete directories in some cases [Matthias Reitinger]
 * Made `check` command exit with the proper exit status
 * Added support for the `HTML_TOC` Redcarpet renderer
 * Made `stale` check honor files excluded by the pruner
@@ -689,7 +804,7 @@ Fixes:
 
 * Made passthrough rules be inserted in the right place [Gregory Pakosz]
 * Fixed crashes in the progress indicator when compiling
-* Made auto-pruning honor excluded files [Greg Karékinian]
+* Made auto-pruning honor excluded files [Grégory Karékinian]
 * Made lack of which/where not crash watch command
 
 Improvements:
@@ -933,7 +1048,7 @@ Changed:
 * The `filesystem` data source is now known as `filesystem_verbose`
 * Meta files and content files are now optional
 * The `filesystem_compact` and `filesystem_combined` data sources have been merged into a new `filesystem_unified` data source
-* The metadata section in `filesystem_unified` is now optional [Christopher Eppstein]
+* The metadata section in `filesystem_unified` is now optional [Chris Eppstein]
 * The `--server` autocompile option is now known as `--handler`
 * Assigns in filters are now available as instance variables and methods
 * The `#breadcrumbs_trail` function now allows missing parents
@@ -1210,7 +1325,7 @@ Removed:
 
 ## 1.2 (2007-06-05)
 
-* Sites now have an `assets` directory, whose contents are copied to the `output` directory when compiling [Soryu]
+* Sites now have an `assets` directory, whose contents are copied to the `output` directory when compiling [Stanley Rost]
 * Added support for non-eRuby layouts (Markaby, Haml, Liquid, …)
 * Added more filters (Markaby, Haml, Liquid, RDoc [Dmitry Bilunov])
 * Improved error reporting
diff --git a/README.md b/README.md
index 52b9eb0..4440185 100644
--- a/README.md
+++ b/README.md
@@ -19,4 +19,4 @@ Contributions are greatly appreciated! Consult the [Development guidelines](http
 
 Many thanks to everyone who has contributed to Nanoc in one way or another:
 
-Ale Muñoz, Alexander Mankuta, Arnau Siches, Ben Armston, Bil Bas, Brian Candler, Bruno Dufour, Chris Eppstein, Christian Plessl, Colin Barrett, Colin Seymour, Croath Liu, Damien Pollet, Dan Callahan, Daniel Hofstetter, Daniel Mendler, Daniel Wollschlaeger, David Alexander, David Everitt, Denis Defreyne, Dennis Sutch, Devon Luke Buchanan, Dmitry Bilunov, Eric Sunshine, Erik Hollensbe, Fabian Buch, Felix Hanley, Garen Torikian, Go Maeda, Gregory Pakosz, Grégory Karékinian, Guilherme Garnie [...]
+Ale Muñoz, Alexander Mankuta, Andy Drop, Arnau Siches, Ben Armston, Bil Bas, Brian Candler, Bruno Dufour, Chris Chapman, Chris Eppstein, Christian Plessl, Colin Barrett, Colin Seymour, Croath Liu, Damien Pollet, Dan Callahan, Daniel Hofstetter, Daniel Mendler, Daniel Wollschlaeger, David Alexander, David Everitt, Denis Defreyne, Dennis Sutch, Devon Luke Buchanan, Dmitry Bilunov, Eric Sunshine, Erik Hollensbe, Fabian Buch, Felix Hanley, Garen Torikian, Go Maeda, Grégory Karékinian, Gregor [...]
diff --git a/Rakefile b/Rakefile
index fb3f6e4..7c1a237 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,3 +1,20 @@
-Rake.add_rakelib 'tasks'
+require 'rubocop/rake_task'
+require 'rspec/core/rake_task'
+require 'rake/testtask'
+require 'coveralls/rake/task'
 
-task default: [:test, :rubocop]
+RuboCop::RakeTask.new(:rubocop)
+
+Coveralls::RakeTask.new
+
+Rake::TestTask.new(:test_all) do |t|
+  t.test_files = Dir['test/**/*_spec.rb'] + Dir['test/**/test_*.rb']
+  t.libs << 'test'
+end
+
+RSpec::Core::RakeTask.new(:spec)
+
+task test: [:spec, :test_all, :rubocop]
+task test_ci: [:test, :'coveralls:push']
+
+task default: :test
diff --git a/doc/yardoc_handlers/identifier.rb b/doc/yardoc_handlers/identifier.rb
deleted file mode 100644
index 37a6ca3..0000000
--- a/doc/yardoc_handlers/identifier.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-class NanocIdentifierHandler < ::YARD::Handlers::Ruby::AttributeHandler
-  # e.g. identifier :foo, :bar
-
-  handles method_call(:identifier), method_call(:identifiers)
-  namespace_only
-
-  def process
-    identifiers = statement.parameters(false).map { |param| param.jump(:ident)[0] }
-    namespace['nanoc_identifiers'] = identifiers
-  end
-end
-
-class NanocRegisterFilterHandler < ::YARD::Handlers::Ruby::AttributeHandler
-  # e.g. Nanoc::Filter.register '::Nanoc::Filters::AsciiDoc', :asciidoc
-
-  handles method_call(:register)
-  namespace_only
-
-  def process
-    target = statement.jump(:const_path_ref)
-    return if target != s(:const_path_ref, s(:var_ref, s(:const, 'Nanoc')), s(:const, 'Filter'))
-
-    class_name = statement.jump(:string_literal).jump(:tstring_content)[0]
-    identifier = statement.jump(:symbol_literal).jump(:ident)[0]
-
-    obj = YARD::Registry.at(class_name.sub(/^::/, ''))
-    obj['nanoc_identifiers'] ||= []
-    obj['nanoc_identifiers'] << identifier
-  end
-end
diff --git a/doc/yardoc_templates/default/layout/html/footer.erb b/doc/yardoc_templates/default/layout/html/footer.erb
deleted file mode 100644
index 17cac0c..0000000
--- a/doc/yardoc_templates/default/layout/html/footer.erb
+++ /dev/null
@@ -1,19 +0,0 @@
-<%= superb %>
-<script type="text/javascript">
-    var _gaq = _gaq || [];
-    var pluginUrl = '//www.google-analytics.com/plugins/ga/inpage_linkid.js';
-    _gaq.push(['_require', 'inpage_linkid', pluginUrl]);
-    _gaq.push(['_setAccount', 'UA-15639968-1']);
-    _gaq.push(['_setDomainName', 'nanoc.ws']);
-    _gaq.push(['_setAllowLinker', true]);
-    _gaq.push(['_trackPageview']);
-
-    (function() {
-        var ga = document.createElement('script');
-        ga.type = 'text/javascript';
-        ga.async = true;
-        ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
-        var s = document.getElementsByTagName('script')[0];
-        s.parentNode.insertBefore(ga, s);
-     })();
-</script>
diff --git a/lib/nanoc.rb b/lib/nanoc.rb
index 66fdb89..accd1cb 100644
--- a/lib/nanoc.rb
+++ b/lib/nanoc.rb
@@ -27,11 +27,13 @@ require 'ref'
 # Load general requirements
 require 'digest'
 require 'enumerator'
+require 'fiber'
 require 'fileutils'
 require 'forwardable'
 require 'pathname'
 require 'pstore'
 require 'set'
+require 'singleton'
 require 'tempfile'
 require 'thread'
 require 'time'
@@ -41,6 +43,8 @@ require 'English'
 # Load Nanoc
 require 'nanoc/version'
 require 'nanoc/base'
+require 'nanoc/checking'
+require 'nanoc/deploying'
 require 'nanoc/extra'
 require 'nanoc/data_sources'
 require 'nanoc/filters'
diff --git a/lib/nanoc/base.rb b/lib/nanoc/base.rb
index 23ec368..1b78b26 100644
--- a/lib/nanoc/base.rb
+++ b/lib/nanoc/base.rb
@@ -1,28 +1,13 @@
-module Nanoc
-  autoload 'Error',                'nanoc/base/error'
-  autoload 'Filter',               'nanoc/base/compilation/filter'
-end
-
 # @api private
 module Nanoc::Int
-  # Load helper classes
-  autoload 'Context',              'nanoc/base/context'
-  autoload 'Checksummer',          'nanoc/base/checksummer'
-  autoload 'DirectedGraph',        'nanoc/base/directed_graph'
-  autoload 'Errors',               'nanoc/base/errors'
-  autoload 'Memoization',          'nanoc/base/memoization'
-  autoload 'PluginRegistry',       'nanoc/base/plugin_registry'
-
-  # Load compilation classes
-  autoload 'Compiler',             'nanoc/base/compilation/compiler'
-  autoload 'DependencyTracker',    'nanoc/base/compilation/dependency_tracker'
-  autoload 'ItemRepRepo',          'nanoc/base/compilation/item_rep_repo'
-  autoload 'OutdatednessChecker',  'nanoc/base/compilation/outdatedness_checker'
-  autoload 'OutdatednessReasons',  'nanoc/base/compilation/outdatedness_reasons'
 end
 
 require_relative 'base/core_ext'
 require_relative 'base/contracts_support'
+require_relative 'base/memoization'
+require_relative 'base/plugin_registry'
+require_relative 'base/error'
+require_relative 'base/errors'
 
 require_relative 'base/entities'
 require_relative 'base/feature'
diff --git a/lib/nanoc/base/compilation/compiler.rb b/lib/nanoc/base/compilation/compiler.rb
deleted file mode 100644
index 38de206..0000000
--- a/lib/nanoc/base/compilation/compiler.rb
+++ /dev/null
@@ -1,300 +0,0 @@
-module Nanoc::Int
-  # Responsible for compiling a site’s item representations.
-  #
-  # The compilation process makes use of notifications (see
-  # {Nanoc::Int::NotificationCenter}) to track dependencies between items,
-  # layouts, etc. The following notifications are used:
-  #
-  # * `compilation_started` — indicates that the compiler has started
-  #   compiling this item representation. Has one argument: the item
-  #   representation itself. Only one item can be compiled at a given moment;
-  #   therefore, it is not possible to get two consecutive
-  #   `compilation_started` notifications without also getting a
-  #   `compilation_ended` notification in between them.
-  #
-  # * `compilation_ended` — indicates that the compiler has finished compiling
-  #   this item representation (either successfully or with failure). Has one
-  #   argument: the item representation itself.
-  #
-  # * `processing_started` — indicates that the compiler has started
-  #   processing the specified object, which can be an item representation
-  #   (when it is compiled) or a layout (when it is used to lay out an item
-  #   representation or when it is used as a partial)
-  #
-  # * `processing_ended` — indicates that the compiler has finished processing
-  #   the specified object.
-  #
-  # @api private
-  class Compiler
-    # @api private
-    attr_reader :site
-
-    # The compilation stack. When the compiler begins compiling a rep or a
-    # layout, it will be placed on the stack; when it is done compiling the
-    # rep or layout, it will be removed from the stack.
-    #
-    # @return [Array] The compilation stack
-    attr_reader :stack
-
-    # @api private
-    attr_reader :compiled_content_cache
-
-    # @api private
-    attr_reader :checksum_store
-
-    # @api private
-    attr_reader :rule_memory_store
-
-    # @api private
-    attr_reader :action_provider
-
-    # @api private
-    attr_reader :dependency_store
-
-    # @api private
-    attr_reader :outdatedness_checker
-
-    # @api private
-    attr_reader :reps
-
-    def initialize(site, compiled_content_cache:, checksum_store:, rule_memory_store:, action_provider:, dependency_store:, outdatedness_checker:, reps:)
-      @site = site
-
-      @compiled_content_cache = compiled_content_cache
-      @checksum_store         = checksum_store
-      @rule_memory_store      = rule_memory_store
-      @dependency_store       = dependency_store
-      @outdatedness_checker   = outdatedness_checker
-      @reps                   = reps
-      @action_provider        = action_provider
-
-      @stack = []
-    end
-
-    def run_all
-      @action_provider.preprocess(@site)
-      build_reps
-      prune
-      run
-      @action_provider.postprocess(@site, @reps)
-    end
-
-    def run
-      load_stores
-      @site.freeze
-
-      # Determine which reps need to be recompiled
-      forget_dependencies_if_outdated
-
-      @stack = []
-      compile_reps
-      store
-    ensure
-      Nanoc::Int::TempFilenameFactory.instance.cleanup(
-        Nanoc::Filter::TMP_BINARY_ITEMS_DIR,
-      )
-      Nanoc::Int::TempFilenameFactory.instance.cleanup(
-        Nanoc::Int::ItemRepWriter::TMP_TEXT_ITEMS_DIR,
-      )
-    end
-
-    def load_stores
-      # FIXME: icky hack to update the dependency store’s list of objects
-      # (does not include preprocessed objects otherwise)
-      dependency_store.objects = site.items.to_a + site.layouts.to_a
-
-      stores.each(&:load)
-    end
-
-    # Store the modified helper data used for compiling the site.
-    #
-    # @return [void]
-    def store
-      # Calculate rule memory
-      (@reps.to_a + @site.layouts.to_a).each do |obj|
-        rule_memory_store[obj] = action_provider.memory_for(obj).serialize
-      end
-
-      # Calculate checksums
-      objects_to_checksum =
-        site.items.to_a + site.layouts.to_a + site.code_snippets + [site.config]
-      objects_to_checksum.each do |obj|
-        checksum_store[obj] = Nanoc::Int::Checksummer.calc(obj)
-      end
-
-      # Store
-      stores.each(&:store)
-    end
-
-    def build_reps
-      builder = Nanoc::Int::ItemRepBuilder.new(
-        site, action_provider, @reps
-      )
-      builder.run
-    end
-
-    # @param [Nanoc::Int::ItemRep] rep The item representation for which the
-    #   assigns should be fetched
-    #
-    # @return [Hash] The assigns that should be used in the next filter/layout
-    #   operation
-    #
-    # @api private
-    def assigns_for(rep, dependency_tracker)
-      content_or_filename_assigns =
-        if rep.binary?
-          { filename: rep.snapshot_contents[:last].filename }
-        else
-          { content: rep.snapshot_contents[:last].string }
-        end
-
-      view_context = create_view_context(dependency_tracker)
-
-      content_or_filename_assigns.merge(
-        item: Nanoc::ItemWithRepsView.new(rep.item, view_context),
-        rep: Nanoc::ItemRepView.new(rep, view_context),
-        item_rep: Nanoc::ItemRepView.new(rep, view_context),
-        items: Nanoc::ItemCollectionWithRepsView.new(site.items, view_context),
-        layouts: Nanoc::LayoutCollectionView.new(site.layouts, view_context),
-        config: Nanoc::ConfigView.new(site.config, view_context),
-      )
-    end
-
-    def create_view_context(dependency_tracker)
-      Nanoc::ViewContext.new(
-        reps: @reps,
-        items: @site.items,
-        dependency_tracker: dependency_tracker,
-        compiler: self,
-      )
-    end
-
-    # @api private
-    def filter_name_and_args_for_layout(layout)
-      mem = action_provider.memory_for(layout)
-      if mem.nil? || mem.size != 1 || !mem[0].is_a?(Nanoc::Int::RuleMemoryActions::Filter)
-        # FIXME: Provide a nicer error message
-        raise Nanoc::Int::Errors::Generic, "No rule memory found for #{layout.identifier}"
-      end
-      [mem[0].filter_name, mem[0].params]
-    end
-
-    private
-
-    def prune
-      if site.config[:prune][:auto_prune]
-        Nanoc::Extra::Pruner.new(site, exclude: prune_config_exclude).run
-      end
-    end
-
-    def prune_config
-      site.config[:prune] || {}
-    end
-
-    def prune_config_exclude
-      prune_config[:exclude] || {}
-    end
-
-    def compile_reps
-      # Listen to processing start/stop
-      Nanoc::Int::NotificationCenter.on(:processing_started, self) { |obj| @stack.push(obj) }
-      Nanoc::Int::NotificationCenter.on(:processing_ended, self) { |_obj| @stack.pop }
-
-      # Assign snapshots
-      @reps.each do |rep|
-        rep.snapshot_defs = action_provider.snapshots_defs_for(rep)
-      end
-
-      # Find item reps to compile and compile them
-      selector = Nanoc::Int::ItemRepSelector.new(@reps)
-      selector.each do |rep|
-        @stack = []
-        compile_rep(rep)
-      end
-    ensure
-      Nanoc::Int::NotificationCenter.remove(:processing_started, self)
-      Nanoc::Int::NotificationCenter.remove(:processing_ended,   self)
-    end
-
-    # Compiles the given item representation.
-    #
-    # This method should not be called directly; please use
-    # {Nanoc::Int::Compiler#run} instead, and pass this item representation's item
-    # as its first argument.
-    #
-    # @param [Nanoc::Int::ItemRep] rep The rep that is to be compiled
-    #
-    # @return [void]
-    def compile_rep(rep)
-      dependency_tracker = Nanoc::Int::DependencyTracker.new(@dependency_store)
-
-      Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
-      Nanoc::Int::NotificationCenter.post(:processing_started,  rep)
-      dependency_tracker.enter(rep.item)
-
-      if can_reuse_content_for_rep?(rep)
-        Nanoc::Int::NotificationCenter.post(:cached_content_used, rep)
-        rep.snapshot_contents = compiled_content_cache[rep]
-      else
-        recalculate_content_for_rep(rep, dependency_tracker)
-      end
-
-      rep.compiled = true
-      compiled_content_cache[rep] = rep.snapshot_contents
-
-      Nanoc::Int::NotificationCenter.post(:processing_ended,  rep)
-      Nanoc::Int::NotificationCenter.post(:compilation_ended, rep)
-    rescue => e
-      rep.forget_progress
-      Nanoc::Int::NotificationCenter.post(:compilation_failed, rep, e)
-      raise e
-    ensure
-      dependency_tracker.exit(rep.item)
-    end
-
-    # @return [Boolean]
-    def can_reuse_content_for_rep?(rep)
-      !outdatedness_checker.outdated?(rep) && compiled_content_cache[rep]
-    end
-
-    # @return [void]
-    def recalculate_content_for_rep(rep, dependency_tracker)
-      executor = Nanoc::Int::Executor.new(self, dependency_tracker)
-
-      action_provider.memory_for(rep).each do |action|
-        case action
-        when Nanoc::Int::RuleMemoryActions::Filter
-          executor.filter(rep, action.filter_name, action.params)
-        when Nanoc::Int::RuleMemoryActions::Layout
-          executor.layout(rep, action.layout_identifier, action.params)
-        when Nanoc::Int::RuleMemoryActions::Snapshot
-          executor.snapshot(rep, action.snapshot_name, final: action.final?, path: action.path)
-        else
-          raise "Internal inconsistency: unknown action #{action.inspect}"
-        end
-      end
-    end
-
-    # Clears the list of dependencies for items that will be recompiled.
-    #
-    # @return [void]
-    def forget_dependencies_if_outdated
-      @site.items.each do |i|
-        if @reps[i].any? { |r| outdatedness_checker.outdated?(r) }
-          @dependency_store.forget_dependencies_for(i)
-        end
-      end
-    end
-
-    # Returns all stores that can load/store data that can be used for
-    # compilation.
-    def stores
-      [
-        checksum_store,
-        compiled_content_cache,
-        @dependency_store,
-        rule_memory_store,
-      ]
-    end
-  end
-end
diff --git a/lib/nanoc/base/compilation/dependency_tracker.rb b/lib/nanoc/base/compilation/dependency_tracker.rb
deleted file mode 100644
index 005a148..0000000
--- a/lib/nanoc/base/compilation/dependency_tracker.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-module Nanoc::Int
-  # @api private
-  class DependencyTracker
-    class Null
-      def enter(_obj)
-      end
-
-      def exit(_obj)
-      end
-
-      def bounce(_obj)
-      end
-    end
-
-    include Nanoc::Int::ContractsSupport
-
-    def initialize(dependency_store)
-      @dependency_store = dependency_store
-      @stack = []
-    end
-
-    contract C::Or[Nanoc::Int::Item, Nanoc::Int::Layout] => C::Any
-    def enter(obj)
-      unless @stack.empty?
-        Nanoc::Int::NotificationCenter.post(:dependency_created, @stack.last, obj)
-        @dependency_store.record_dependency(@stack.last, obj)
-      end
-
-      @stack.push(obj)
-    end
-
-    contract C::Or[Nanoc::Int::Item, Nanoc::Int::Layout] => C::Any
-    def exit(_obj)
-      @stack.pop
-    end
-
-    contract C::Or[Nanoc::Int::Item, Nanoc::Int::Layout] => C::Any
-    def bounce(obj)
-      enter(obj)
-      exit(obj)
-    end
-  end
-end
diff --git a/lib/nanoc/base/compilation/outdatedness_checker.rb b/lib/nanoc/base/compilation/outdatedness_checker.rb
deleted file mode 100644
index eabab56..0000000
--- a/lib/nanoc/base/compilation/outdatedness_checker.rb
+++ /dev/null
@@ -1,210 +0,0 @@
-module Nanoc::Int
-  # Responsible for determining whether an item or a layout is outdated.
-  #
-  # @api private
-  class OutdatednessChecker
-    extend Nanoc::Int::Memoization
-
-    attr_reader :checksum_store
-    attr_reader :dependency_store
-    attr_reader :rule_memory_store
-    attr_reader :site
-
-    Reasons = Nanoc::Int::OutdatednessReasons
-
-    # @param [Nanoc::Int::Site] site
-    # @param [Nanoc::Int::ChecksumStore] checksum_store
-    # @param [Nanoc::Int::DependencyStore] dependency_store
-    # @param [Nanoc::Int::RuleMemoryStore] rule_memory_store
-    # @param [Nanoc::Int::ActionProvider] action_provider
-    # @param [Nanoc::Int::ItemRepRepo] reps
-    def initialize(site:, checksum_store:, dependency_store:, rule_memory_store:, action_provider:, reps:)
-      @site = site
-      @checksum_store = checksum_store
-      @dependency_store = dependency_store
-      @rule_memory_store = rule_memory_store
-      @action_provider = action_provider
-      @reps = reps
-
-      @basic_outdatedness_reasons = {}
-      @outdatedness_reasons = {}
-      @objects_outdated_due_to_dependencies = {}
-    end
-
-    # Checks whether the given object is outdated and therefore needs to be
-    # recompiled.
-    #
-    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
-    #   whose outdatedness should be checked.
-    #
-    # @return [Boolean] true if the object is outdated, false otherwise
-    def outdated?(obj)
-      !outdatedness_reason_for(obj).nil?
-    end
-
-    # Calculates the reason why the given object is outdated.
-    #
-    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
-    #   whose outdatedness reason should be calculated.
-    #
-    # @return [Reasons::Generic, nil] The reason why the
-    #   given object is outdated, or nil if the object is not outdated.
-    def outdatedness_reason_for(obj)
-      reason = basic_outdatedness_reason_for(obj)
-      if reason.nil? && outdated_due_to_dependencies?(obj)
-        reason = Reasons::DependenciesOutdated
-      end
-      reason
-    end
-    memoize :outdatedness_reason_for
-
-    private
-
-    # Checks whether the given object is outdated and therefore needs to be
-    # recompiled. This method does not take dependencies into account; use
-    # {#outdated?} if you want to include dependencies in the outdatedness
-    # check.
-    #
-    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
-    #   whose outdatedness should be checked.
-    #
-    # @return [Boolean] true if the object is outdated, false otherwise
-    def basic_outdated?(obj)
-      !basic_outdatedness_reason_for(obj).nil?
-    end
-
-    # Calculates the reason why the given object is outdated. This method does
-    # not take dependencies into account; use {#outdatedness_reason_for?} if
-    # you want to include dependencies in the outdatedness check.
-    #
-    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
-    #   whose outdatedness reason should be calculated.
-    #
-    # @return [Reasons::Generic, nil] The reason why the
-    #   given object is outdated, or nil if the object is not outdated.
-    def basic_outdatedness_reason_for(obj)
-      case obj
-      when Nanoc::Int::ItemRep
-        # Outdated if rules outdated
-        return Reasons::RulesModified if
-          rule_memory_differs_for(obj)
-
-        # Outdated if checksums are missing or different
-        return Reasons::NotEnoughData unless checksums_available?(obj.item)
-        return Reasons::SourceModified unless checksums_identical?(obj.item)
-
-        # Outdated if compiled file doesn't exist (yet)
-        return Reasons::NotWritten if obj.raw_path && !File.file?(obj.raw_path)
-
-        # Outdated if code snippets outdated
-        return Reasons::CodeSnippetsModified if site.code_snippets.any? do |cs|
-          object_modified?(cs)
-        end
-
-        # Outdated if configuration outdated
-        return Reasons::ConfigurationModified if object_modified?(site.config)
-
-        # Not outdated
-        return nil
-      when Nanoc::Int::Item
-        @reps[obj].find { |rep| basic_outdatedness_reason_for(rep) }
-      when Nanoc::Int::Layout
-        # Outdated if rules outdated
-        return Reasons::RulesModified if
-          rule_memory_differs_for(obj)
-
-        # Outdated if checksums are missing or different
-        return Reasons::NotEnoughData unless checksums_available?(obj)
-        return Reasons::SourceModified unless checksums_identical?(obj)
-
-        # Not outdated
-        return nil
-      else
-        raise "do not know how to check outdatedness of #{obj.inspect}"
-      end
-    end
-    memoize :basic_outdatedness_reason_for
-
-    # Checks whether the given object is outdated due to dependencies.
-    #
-    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
-    #   whose outdatedness should be checked.
-    #
-    # @param [Set] processed The collection of items that has been visited
-    #   during this outdatedness check. This is used to prevent checks for
-    #   items that (indirectly) depend on their own from looping
-    #   indefinitely. It should not be necessary to pass this a custom value.
-    #
-    # @return [Boolean] true if the object is outdated, false otherwise
-    def outdated_due_to_dependencies?(obj, processed = Hamster::Set.new)
-      # Convert from rep to item if necessary
-      obj = obj.item if obj.is_a?(Nanoc::Int::ItemRep)
-
-      # Get from cache
-      if @objects_outdated_due_to_dependencies.key?(obj)
-        return @objects_outdated_due_to_dependencies[obj]
-      end
-
-      # Check processed
-      # Don’t return true; the false will be or’ed into a true if there
-      # really is a dependency that is causing outdatedness.
-      return false if processed.include?(obj)
-
-      # Calculate
-      is_outdated = dependency_store.objects_causing_outdatedness_of(obj).any? do |other|
-        other.nil? || basic_outdated?(other) || outdated_due_to_dependencies?(other, processed.merge([obj]))
-      end
-
-      # Cache
-      @objects_outdated_due_to_dependencies[obj] = is_outdated
-
-      # Done
-      is_outdated
-    end
-
-    # @param [Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The layout or item
-    #   representation to check the rule memory for
-    #
-    # @return [Boolean] true if the rule memory for the given item
-    #   represenation has changed, false otherwise
-    def rule_memory_differs_for(obj)
-      !rule_memory_store[obj].eql?(@action_provider.memory_for(obj).serialize)
-    end
-    memoize :rule_memory_differs_for
-
-    # @param obj The object to create a checksum for
-    #
-    # @return [String] The digest
-    def calc_checksum(obj)
-      Nanoc::Int::Checksummer.calc(obj)
-    end
-    memoize :calc_checksum
-
-    # @param obj
-    #
-    # @return [Boolean] false if either the new or the old checksum for the
-    #   given object is not available, true if both checksums are available
-    def checksums_available?(obj)
-      checksum_store[obj] && calc_checksum(obj)
-    end
-    memoize :checksums_available?
-
-    # @param obj
-    #
-    # @return [Boolean] false if the old and new checksums for the given
-    #   object differ, true if they are identical
-    def checksums_identical?(obj)
-      checksum_store[obj] == calc_checksum(obj)
-    end
-    memoize :checksums_identical?
-
-    # @param obj
-    #
-    # @return [Boolean] true if the old and new checksums for the given object
-    #   are available and identical, false otherwise
-    def object_modified?(obj)
-      !checksums_available?(obj) || !checksums_identical?(obj)
-    end
-    memoize :object_modified?
-  end
-end
diff --git a/lib/nanoc/base/contracts_support.rb b/lib/nanoc/base/contracts_support.rb
index e8079cc..4cc7071 100644
--- a/lib/nanoc/base/contracts_support.rb
+++ b/lib/nanoc/base/contracts_support.rb
@@ -29,18 +29,58 @@ module Nanoc::Int
       Or          = Ignorer.instance
       Func        = Ignorer.instance
       RespondTo   = Ignorer.instance
+      Named       = Ignorer.instance
+      IterOf      = Ignorer.instance
+      HashOf      = Ignorer.instance
 
-      def contract(*args)
-      end
+      def contract(*args); end
     end
 
     module EnabledContracts
+      class AbstractContract
+        def self.[](*vals)
+          new(*vals)
+        end
+      end
+
+      class Named < AbstractContract
+        def initialize(name)
+          @name = name
+        end
+
+        def valid?(val)
+          val.is_a?(Kernel.const_get(@name))
+        end
+
+        def inspect
+          "#{self.class}(#{@name})"
+        end
+      end
+
+      class IterOf < AbstractContract
+        def initialize(contract)
+          @contract = contract
+        end
+
+        def valid?(val)
+          val.respond_to?(:each) && val.all? { |v| Contract.valid?(v, @contract) }
+        end
+
+        def inspect
+          "#{self.class}(#{@contract})"
+        end
+      end
+
       def contract(*args)
         Contract(*args)
       end
     end
 
-    def self.included(base)
+    def self.setup_once
+      @_contracts_support__setup ||= false
+      return @_contracts_support__should_enable if @_contracts_support__setup
+      @_contracts_support__setup = true
+
       contracts_loadable =
         begin
           require 'contracts'
@@ -49,7 +89,19 @@ module Nanoc::Int
           false
         end
 
-      should_enable = contracts_loadable && !ENV.key?('DISABLE_CONTRACTS')
+      @_contracts_support__should_enable = contracts_loadable && !ENV.key?('DISABLE_CONTRACTS')
+
+      if @_contracts_support__should_enable
+        # FIXME: ugly
+        ::Contracts.const_set('Named', EnabledContracts::Named)
+        ::Contracts.const_set('IterOf', EnabledContracts::IterOf)
+      end
+
+      @_contracts_support__should_enable
+    end
+
+    def self.included(base)
+      should_enable = setup_once
 
       if should_enable
         unless base.include?(::Contracts::Core)
diff --git a/lib/nanoc/base/core_ext/array.rb b/lib/nanoc/base/core_ext/array.rb
index 98fafa3..d8e077d 100644
--- a/lib/nanoc/base/core_ext/array.rb
+++ b/lib/nanoc/base/core_ext/array.rb
@@ -20,8 +20,6 @@ module Nanoc::ArrayExtensions
   # @see Hash#__nanoc_freeze_recursively
   #
   # @return [void]
-  #
-  # @since 3.2.0
   def __nanoc_freeze_recursively
     return if frozen?
     freeze
diff --git a/lib/nanoc/base/core_ext/hash.rb b/lib/nanoc/base/core_ext/hash.rb
index c0890c2..84bb22a 100644
--- a/lib/nanoc/base/core_ext/hash.rb
+++ b/lib/nanoc/base/core_ext/hash.rb
@@ -22,8 +22,6 @@ module Nanoc::HashExtensions
   # @see Array#__nanoc_freeze_recursively
   #
   # @return [void]
-  #
-  # @since 3.2.0
   def __nanoc_freeze_recursively
     return if frozen?
     freeze
diff --git a/lib/nanoc/base/entities.rb b/lib/nanoc/base/entities.rb
index 5794d7d..f55b90b 100644
--- a/lib/nanoc/base/entities.rb
+++ b/lib/nanoc/base/entities.rb
@@ -1,7 +1,10 @@
+require_relative 'entities/context'
+require_relative 'entities/directed_graph'
+
 require_relative 'entities/identifier'
 require_relative 'entities/content'
-require_relative 'entities/rule_memory_action'
-require_relative 'entities/rule_memory_actions'
+require_relative 'entities/processing_action'
+require_relative 'entities/processing_actions'
 
 require_relative 'entities/code_snippet'
 require_relative 'entities/configuration'
@@ -12,6 +15,11 @@ require_relative 'entities/item'
 require_relative 'entities/item_rep'
 require_relative 'entities/layout'
 require_relative 'entities/pattern'
+require_relative 'entities/props'
 require_relative 'entities/rule_memory'
 require_relative 'entities/site'
 require_relative 'entities/snapshot_def'
+
+require_relative 'entities/outdatedness_status'
+require_relative 'entities/outdatedness_reasons'
+require_relative 'entities/dependency'
diff --git a/lib/nanoc/base/entities/configuration.rb b/lib/nanoc/base/entities/configuration.rb
index a99c562..f1cefee 100644
--- a/lib/nanoc/base/entities/configuration.rb
+++ b/lib/nanoc/base/entities/configuration.rb
@@ -33,11 +33,21 @@ module Nanoc::Int
       string_pattern_type: 'glob',
     }.freeze
 
-    contract Hash => C::Any
+    # @return [String, nil] The active environment for the configuration
+    attr_reader :env_name
+
+    # Configuration environments property key
+    ENVIRONMENTS_CONFIG_KEY = :environments
+    NANOC_ENV = 'NANOC_ENV'.freeze
+    NANOC_ENV_DEFAULT = 'default'.freeze
+
+    contract C::KeywordArgs[hash: C::Optional[Hash], env_name: C::Maybe[String]] => C::Any
     # Creates a new configuration with the given hash.
     #
     # @param [Hash] hash The actual configuration hash
-    def initialize(hash = {})
+    # @param [String, nil] env_name The active environment for this configuration
+    def initialize(hash: {}, env_name: nil)
+      @env_name = env_name
       @wrapped = hash.__nanoc_symbolize_keys_recursively
     end
 
@@ -48,7 +58,19 @@ module Nanoc::Int
         DEFAULT_DATA_SOURCE_CONFIG.merge(ds)
       end
 
-      self.class.new(new_wrapped)
+      self.class.new(hash: new_wrapped)
+    end
+
+    def with_environment
+      return self unless @wrapped.key?(ENVIRONMENTS_CONFIG_KEY)
+
+      # Set active environment
+      env_name = @env_name || ENV.fetch(NANOC_ENV, NANOC_ENV_DEFAULT)
+
+      # Load given environment configuration
+      env_config = @wrapped[ENVIRONMENTS_CONFIG_KEY].fetch(env_name.to_sym, {})
+
+      self.class.new(hash: @wrapped, env_name: env_name).merge(env_config)
     end
 
     contract C::None => Hash
@@ -86,12 +108,12 @@ module Nanoc::Int
 
     contract C::Or[Hash, self] => self
     def merge(hash)
-      self.class.new(@wrapped.merge(hash.to_h))
+      self.class.new(hash: @wrapped.merge(hash.to_h), env_name: @env_name)
     end
 
     contract C::Any => self
     def without(key)
-      self.class.new(@wrapped.reject { |k, _v| k == key })
+      self.class.new(hash: @wrapped.reject { |k, _v| k == key }, env_name: @env_name)
     end
 
     contract C::Any => self
diff --git a/lib/nanoc/base/context.rb b/lib/nanoc/base/entities/context.rb
similarity index 83%
rename from lib/nanoc/base/context.rb
rename to lib/nanoc/base/entities/context.rb
index 93357aa..95cd15e 100644
--- a/lib/nanoc/base/context.rb
+++ b/lib/nanoc/base/entities/context.rb
@@ -24,12 +24,9 @@ module Nanoc::Int
     #     end
     #     # => "I am Max Payne and I am hiding in a cheap motel."
     def initialize(hash)
+      metaclass = class << self; self; end
       hash.each_pair do |key, value|
-        # Build instance variable
         instance_variable_set('@' + key.to_s, value)
-
-        # Define method
-        metaclass = (class << self; self; end)
         metaclass.send(:define_method, key) { value }
       end
     end
@@ -37,8 +34,15 @@ module Nanoc::Int
     # Returns a binding for this instance.
     #
     # @return [Binding] A binding for this instance
+    # rubocop:disable Style/AccessorMethodName
     def get_binding
       binding
     end
+    # rubocop:enable Style/AccessorMethodName
+
+    def include(mod)
+      metaclass = class << self; self; end
+      metaclass.instance_eval { include(mod) }
+    end
   end
 end
diff --git a/lib/nanoc/base/entities/dependency.rb b/lib/nanoc/base/entities/dependency.rb
new file mode 100644
index 0000000..bf4c1e9
--- /dev/null
+++ b/lib/nanoc/base/entities/dependency.rb
@@ -0,0 +1,28 @@
+module Nanoc::Int
+  # @api private
+  # A dependency between two items/layouts.
+  class Dependency
+    include Nanoc::Int::ContractsSupport
+
+    contract C::None => C::Maybe[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]]
+    attr_reader :from
+
+    contract C::None => C::Maybe[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]]
+    attr_reader :to
+
+    contract C::None => Nanoc::Int::Props
+    attr_reader :props
+
+    contract C::Maybe[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]], C::Maybe[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]], Nanoc::Int::Props => C::Any
+    def initialize(from, to, props)
+      @from  = from
+      @to    = to
+      @props = props
+    end
+
+    contract C::None => String
+    def inspect
+      "Dependency(#{@from.inspect} -> #{@to.inspect}, #{@props.inspect})"
+    end
+  end
+end
diff --git a/lib/nanoc/base/directed_graph.rb b/lib/nanoc/base/entities/directed_graph.rb
similarity index 93%
rename from lib/nanoc/base/directed_graph.rb
rename to lib/nanoc/base/entities/directed_graph.rb
index 7460983..acf9b98 100644
--- a/lib/nanoc/base/directed_graph.rb
+++ b/lib/nanoc/base/entities/directed_graph.rb
@@ -41,6 +41,8 @@ module Nanoc::Int
       @from_graph = {}
       @to_graph   = {}
 
+      @edge_props = {}
+
       @roots = Set.new(@vertices.keys)
 
       invalidate_caches
@@ -55,7 +57,7 @@ module Nanoc::Int
     # @param to   Vertex where the edge should end
     #
     # @return [void]
-    def add_edge(from, to)
+    def add_edge(from, to, props: nil)
       add_vertex(from)
       add_vertex(to)
 
@@ -65,6 +67,10 @@ module Nanoc::Int
       @to_graph[to] ||= Set.new
       @to_graph[to] << from
 
+      if props
+        @edge_props[[from, to]] = props
+      end
+
       @roots.delete(to)
 
       invalidate_caches
@@ -78,8 +84,6 @@ module Nanoc::Int
     # @param to   End vertex of the edge
     #
     # @return [void]
-    #
-    # @since 3.2.0
     def delete_edge(from, to)
       @from_graph[from] ||= Set.new
       @from_graph[from].delete(to)
@@ -87,6 +91,8 @@ module Nanoc::Int
       @to_graph[to] ||= Set.new
       @to_graph[to].delete(from)
 
+      @edge_props.delete([from, to])
+
       @roots.add(to) if @to_graph[to].empty?
 
       invalidate_caches
@@ -97,8 +103,6 @@ module Nanoc::Int
     # @param v The vertex to add to the graph
     #
     # @return [void]
-    #
-    # @since 3.2.0
     def add_vertex(v)
       return if @vertices.key?(v)
 
@@ -112,13 +116,12 @@ module Nanoc::Int
     # @param from Vertex from which all edges should be removed
     #
     # @return [void]
-    #
-    # @since 3.2.0
     def delete_edges_from(from)
       return if @from_graph[from].nil?
 
       @from_graph[from].each do |to|
         @to_graph[to].delete(from)
+        @edge_props.delete([from, to])
         @roots.add(to) if @to_graph[to].empty?
       end
       @from_graph.delete(from)
@@ -134,6 +137,7 @@ module Nanoc::Int
 
       @to_graph[to].each do |from|
         @from_graph[from].delete(to)
+        @edge_props.delete([from, to])
       end
       @to_graph.delete(to)
       @roots.add(to)
@@ -144,8 +148,6 @@ module Nanoc::Int
     # @param v Vertex to remove from the graph
     #
     # @return [void]
-    #
-    # @since 3.2.0
     def delete_vertex(v)
       delete_edges_to(v)
       delete_edges_from(v)
@@ -196,6 +198,10 @@ module Nanoc::Int
       @successors[from] ||= recursively_find_vertices(from, :direct_successors_of)
     end
 
+    def props_for(from, to)
+      @edge_props[[from, to]]
+    end
+
     # @return [Array] The list of all vertices in this graph.
     def vertices
       @vertices.keys.sort_by { |v| @vertices[v] }
@@ -208,8 +214,8 @@ module Nanoc::Int
     def edges
       result = []
       @vertices.each_pair do |v1, i1|
-        direct_successors_of(v1).map { |v2| @vertices[v2] }.each do |i2|
-          result << [i1, i2]
+        direct_successors_of(v1).map { |v2| [@vertices[v2], v2] }.each do |i2, v2|
+          result << [i1, i2, @edge_props[[v1, v2]]]
         end
       end
       result
@@ -218,8 +224,6 @@ module Nanoc::Int
     # Returns all root vertices, i.e. vertices where no edge points to.
     #
     # @return [Set] The set of all root vertices in this graph.
-    #
-    # @since 3.2.0
     def roots
       @roots
     end
diff --git a/lib/nanoc/base/entities/document.rb b/lib/nanoc/base/entities/document.rb
index f130fc1..86f64f6 100644
--- a/lib/nanoc/base/entities/document.rb
+++ b/lib/nanoc/base/entities/document.rb
@@ -5,7 +5,7 @@ module Nanoc
       include Nanoc::Int::ContractsSupport
 
       # @return [Nanoc::Int::Content]
-      attr_reader :content
+      attr_accessor :content
 
       # @return [Hash]
       def attributes
@@ -18,24 +18,41 @@ module Nanoc
       # @return [String, nil]
       attr_accessor :checksum_data
 
+      # @return [String, nil]
+      attr_accessor :content_checksum_data
+
+      # @return [String, nil]
+      attr_accessor :attributes_checksum_data
+
       c_content = C::Or[String, Nanoc::Int::Content]
       c_attributes = C::Or[Hash, Proc]
       c_identifier = C::Or[String, Nanoc::Identifier]
-      c_checksum_data = C::Optional[C::Maybe[String]]
+      c_checksum_data = C::KeywordArgs[
+        checksum_data: C::Optional[C::Maybe[String]],
+        content_checksum_data: C::Optional[C::Maybe[String]],
+        attributes_checksum_data: C::Optional[C::Maybe[String]],
+      ]
 
-      contract c_content, c_attributes, c_identifier, C::KeywordArgs[checksum_data: c_checksum_data] => C::Any
+      contract c_content, c_attributes, c_identifier, c_checksum_data => C::Any
       # @param [String, Nanoc::Int::Content] content
       #
       # @param [Hash, Proc] attributes
       #
       # @param [String, Nanoc::Identifier] identifier
       #
-      # @param [String, nil] checksum_data Used to determine whether the document has changed
-      def initialize(content, attributes, identifier, checksum_data: nil)
+      # @param [String, nil] checksum_data
+      #
+      # @param [String, nil] content_checksum_data
+      #
+      # @param [String, nil] attributes_checksum_data
+      def initialize(content, attributes, identifier, checksum_data: nil, content_checksum_data: nil, attributes_checksum_data: nil)
         @content = Nanoc::Int::Content.create(content)
         @attributes = Nanoc::Int::LazyValue.new(attributes).map(&:__nanoc_symbolize_keys_recursively)
         @identifier = Nanoc::Identifier.from(identifier)
+
         @checksum_data = checksum_data
+        @content_checksum_data = content_checksum_data
+        @attributes_checksum_data = attributes_checksum_data
       end
 
       contract C::None => self
diff --git a/lib/nanoc/base/entities/identifiable_collection.rb b/lib/nanoc/base/entities/identifiable_collection.rb
index eaa1995..fe421a1 100644
--- a/lib/nanoc/base/entities/identifiable_collection.rb
+++ b/lib/nanoc/base/entities/identifiable_collection.rb
@@ -11,8 +11,7 @@ module Nanoc::Int
     def_delegator :@objects, :<<
     def_delegator :@objects, :concat
 
-    # FIXME: use Nanoc::Int::Configuration
-    contract C::Any => C::Any
+    contract C::Or[Hash, C::Named['Nanoc::Int::Configuration']] => C::Any
     def initialize(config)
       @config = config
 
diff --git a/lib/nanoc/base/entities/identifier.rb b/lib/nanoc/base/entities/identifier.rb
index 8ed8e82..a62a960 100644
--- a/lib/nanoc/base/entities/identifier.rb
+++ b/lib/nanoc/base/entities/identifier.rb
@@ -98,15 +98,13 @@ module Nanoc
     end
 
     contract C::None => C::Bool
-    # @return [Boolean] True if this is a full-type identifier (i.e. includes
-    #   the extension), false otherwise
+    # Whether or not this is a full identifier (i.e.includes the extension).
     def full?
       @type == :full
     end
 
     contract C::None => C::Bool
-    # @return [Boolean] True if this is a legacy identifier (i.e. does not
-    #   include the extension), false otherwise
+    # Whether or not this is a legacy identifier (i.e. does not include the extension).
     def legacy?
       @type == :legacy
     end
@@ -133,7 +131,7 @@ module Nanoc
     end
 
     contract C::None => String
-    # @return [String]
+    # The identifier, as string, with the last extension removed
     def without_ext
       unless full?
         raise UnsupportedLegacyOperationError
@@ -149,7 +147,7 @@ module Nanoc
     end
 
     contract C::None => C::Maybe[String]
-    # @return [String, nil] The extension, without a leading dot.
+    # The extension, without a leading dot
     def ext
       unless full?
         raise UnsupportedLegacyOperationError
@@ -160,7 +158,7 @@ module Nanoc
     end
 
     contract C::None => String
-    # @return [String]
+    # The identifier, as string, with all extensions removed
     def without_exts
       extname = exts.join('.')
       if !extname.empty?
@@ -171,7 +169,7 @@ module Nanoc
     end
 
     contract C::None => C::ArrayOf[String]
-    # @return [Array] List of extensions, without a leading dot.
+    # The list of extensions, without a leading dot
     def exts
       unless full?
         raise UnsupportedLegacyOperationError
diff --git a/lib/nanoc/base/entities/item_rep.rb b/lib/nanoc/base/entities/item_rep.rb
index 166d985..5cf0cca 100644
--- a/lib/nanoc/base/entities/item_rep.rb
+++ b/lib/nanoc/base/entities/item_rep.rb
@@ -42,7 +42,7 @@ module Nanoc::Int
       @raw_paths  = {}
       @paths      = {}
       @snapshot_defs = []
-      initialize_content
+      @snapshot_contents = { last: @item.content }
 
       # Reset flags
       @compiled = false
@@ -64,48 +64,31 @@ module Nanoc::Int
     # @return [String] The compiled content at the given snapshot (or the
     #   default snapshot if no snapshot is specified)
     def compiled_content(snapshot: nil)
-      # Make sure we're not binary
-      if binary?
-        raise Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem.new(self)
-      end
-
       # Get name of last pre-layout snapshot
       snapshot_name = snapshot || (@snapshot_contents[:pre] ? :pre : :last)
-      is_moving = [:pre, :post, :last].include?(snapshot_name)
 
       # Check existance of snapshot
       snapshot_def = snapshot_defs.reverse.find { |sd| sd.name == snapshot_name }
-      if !is_moving && (snapshot_def.nil? || !snapshot_def.final?)
+      unless snapshot_def
         raise Nanoc::Int::Errors::NoSuchSnapshot.new(self, snapshot_name)
       end
 
       # Verify snapshot is usable
-      is_still_moving =
-        case snapshot_name
-        when :post, :last
-          true
-        when :pre
-          snapshot_def.nil? || !snapshot_def.final?
-        end
-      is_usable_snapshot = @snapshot_contents[snapshot_name] && (compiled? || !is_still_moving)
+      stopped_moving = snapshot_name != :last || compiled?
+      is_usable_snapshot = @snapshot_contents[snapshot_name] && stopped_moving
       unless is_usable_snapshot
-        raise Nanoc::Int::Errors::UnmetDependency.new(self)
+        Fiber.yield(Nanoc::Int::Errors::UnmetDependency.new(self))
+        return compiled_content(snapshot: snapshot)
       end
 
-      @snapshot_contents[snapshot_name].string
-    end
+      # Verify snapshot is not binary
+      snapshot_content = @snapshot_contents[snapshot_name]
+      if snapshot_content.binary?
+        raise Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem.new(self)
+      end
 
-    contract Symbol => C::Bool
-    # Checks whether content exists at a given snapshot.
-    #
-    # @return [Boolean] True if content exists for the snapshot with the
-    #   given name, false otherwise
-    #
-    # @since 3.2.0
-    def snapshot?(snapshot_name)
-      !@snapshot_contents[snapshot_name].nil?
+      snapshot_content.string
     end
-    alias has_snapshot? snapshot?
 
     contract C::KeywordArgs[snapshot: C::Optional[Symbol]] => C::Maybe[String]
     # Returns the item rep’s raw path. It includes the path to the output
@@ -133,18 +116,6 @@ module Nanoc::Int
       @paths[snapshot]
     end
 
-    contract C::None => nil
-    # Resets the compilation progress for this item representation. This is
-    # necessary when an unmet dependency is detected during compilation.
-    #
-    # @api private
-    #
-    # @return [void]
-    def forget_progress
-      initialize_content
-      nil
-    end
-
     # Returns an object that can be used for uniquely identifying objects.
     #
     # @api private
@@ -155,13 +126,7 @@ module Nanoc::Int
     end
 
     def inspect
-      "<#{self.class} name=\"#{name}\" binary=#{binary?} raw_path=\"#{raw_path}\" item.identifier=\"#{item.identifier}\">"
-    end
-
-    private
-
-    def initialize_content
-      @snapshot_contents = { last: @item.content }
+      "<#{self.class} name=\"#{name}\" raw_path=\"#{raw_path}\" item.identifier=\"#{item.identifier}\">"
     end
   end
 end
diff --git a/lib/nanoc/base/compilation/outdatedness_reasons.rb b/lib/nanoc/base/entities/outdatedness_reasons.rb
similarity index 56%
rename from lib/nanoc/base/compilation/outdatedness_reasons.rb
rename to lib/nanoc/base/entities/outdatedness_reasons.rb
index 09b76b9..f23e543 100644
--- a/lib/nanoc/base/compilation/outdatedness_reasons.rb
+++ b/lib/nanoc/base/entities/outdatedness_reasons.rb
@@ -9,39 +9,54 @@ module Nanoc::Int
       # @return [String] A descriptive message for this outdatedness reason
       attr_reader :message
 
+      # @return [Nanoc::Int::Props]
+      attr_reader :props
+
       # @param [String] message The descriptive message for this outdatedness
       #   reason
-      def initialize(message)
+      def initialize(message, props = Nanoc::Int::Props.new)
         @message = message
+        @props = props
       end
     end
 
     CodeSnippetsModified = Generic.new(
       'The code snippets have been modified since the last time the site was compiled.',
+      Props.new(raw_content: true, attributes: true, compiled_content: true, path: true),
     )
 
     ConfigurationModified = Generic.new(
       'The site configuration has been modified since the last time the site was compiled.',
+      Props.new(raw_content: true, attributes: true, compiled_content: true, path: true),
     )
 
     DependenciesOutdated = Generic.new(
       'This item uses content or attributes that have changed since the last time the site was compiled.',
     )
 
-    NotEnoughData = Generic.new(
-      'Not enough data is present to correctly determine whether the item is outdated.',
-    )
-
     NotWritten = Generic.new(
       'This item representation has not yet been written to the output directory (but it does have a path).',
+      Props.new(raw_content: true, attributes: true, compiled_content: true, path: true),
     )
 
     RulesModified = Generic.new(
       'The rules file has been modified since the last time the site was compiled.',
+      Props.new(compiled_content: true, path: true),
+    )
+
+    ContentModified = Generic.new(
+      'The content of this item has been modified since the last time the site was compiled.',
+      Props.new(raw_content: true, compiled_content: true),
+    )
+
+    AttributesModified = Generic.new(
+      'The attributes of this item have been modified since the last time the site was compiled.',
+      Props.new(attributes: true, compiled_content: true),
     )
 
-    SourceModified = Generic.new(
-      'The source file of this item has been modified since the last time the site was compiled.',
+    PathsModified = Generic.new(
+      'One or more output paths of this item have been modified since the last time the site was compiled.',
+      Props.new(path: true),
     )
   end
 end
diff --git a/lib/nanoc/base/entities/outdatedness_status.rb b/lib/nanoc/base/entities/outdatedness_status.rb
new file mode 100644
index 0000000..98049bd
--- /dev/null
+++ b/lib/nanoc/base/entities/outdatedness_status.rb
@@ -0,0 +1,23 @@
+module Nanoc::Int
+  # @api private
+  class OutdatednessStatus
+    attr_reader :reasons
+    attr_reader :props
+
+    def initialize(reasons: [], props: Props.new)
+      @reasons = reasons
+      @props = props
+    end
+
+    def useful_to_apply?(rule)
+      (rule.instance.reason.props.active - @props.active).any?
+    end
+
+    def update(reason)
+      self.class.new(
+        reasons: @reasons + [reason],
+        props: @props.merge(reason.props),
+      )
+    end
+  end
+end
diff --git a/lib/nanoc/base/entities/rule_memory_action.rb b/lib/nanoc/base/entities/processing_action.rb
similarity index 67%
rename from lib/nanoc/base/entities/rule_memory_action.rb
rename to lib/nanoc/base/entities/processing_action.rb
index 6de3756..bdcf5e4 100644
--- a/lib/nanoc/base/entities/rule_memory_action.rb
+++ b/lib/nanoc/base/entities/processing_action.rb
@@ -1,11 +1,11 @@
 module Nanoc::Int
-  class RuleMemoryAction
+  class ProcessingAction
     def serialize
-      raise NotImplementedError.new('Nanoc::RuleMemoryAction subclasses must implement #serialize and #to_s')
+      raise NotImplementedError.new('Nanoc::ProcessingAction subclasses must implement #serialize and #to_s')
     end
 
     def to_s
-      raise NotImplementedError.new('Nanoc::RuleMemoryAction subclasses must implement #serialize and #to_s')
+      raise NotImplementedError.new('Nanoc::ProcessingAction subclasses must implement #serialize and #to_s')
     end
 
     def inspect
diff --git a/lib/nanoc/base/entities/processing_actions.rb b/lib/nanoc/base/entities/processing_actions.rb
new file mode 100644
index 0000000..d33260b
--- /dev/null
+++ b/lib/nanoc/base/entities/processing_actions.rb
@@ -0,0 +1,3 @@
+require_relative 'processing_actions/filter'
+require_relative 'processing_actions/layout'
+require_relative 'processing_actions/snapshot'
diff --git a/lib/nanoc/base/entities/rule_memory_actions/filter.rb b/lib/nanoc/base/entities/processing_actions/filter.rb
similarity index 82%
rename from lib/nanoc/base/entities/rule_memory_actions/filter.rb
rename to lib/nanoc/base/entities/processing_actions/filter.rb
index 0878282..54cc3b7 100644
--- a/lib/nanoc/base/entities/rule_memory_actions/filter.rb
+++ b/lib/nanoc/base/entities/processing_actions/filter.rb
@@ -1,5 +1,5 @@
-module Nanoc::Int::RuleMemoryActions
-  class Filter < Nanoc::Int::RuleMemoryAction
+module Nanoc::Int::ProcessingActions
+  class Filter < Nanoc::Int::ProcessingAction
     # filter :foo
     # filter :foo, params
 
diff --git a/lib/nanoc/base/entities/rule_memory_actions/layout.rb b/lib/nanoc/base/entities/processing_actions/layout.rb
similarity index 84%
rename from lib/nanoc/base/entities/rule_memory_actions/layout.rb
rename to lib/nanoc/base/entities/processing_actions/layout.rb
index 004c350..2b5c359 100644
--- a/lib/nanoc/base/entities/rule_memory_actions/layout.rb
+++ b/lib/nanoc/base/entities/processing_actions/layout.rb
@@ -1,5 +1,5 @@
-module Nanoc::Int::RuleMemoryActions
-  class Layout < Nanoc::Int::RuleMemoryAction
+module Nanoc::Int::ProcessingActions
+  class Layout < Nanoc::Int::ProcessingAction
     # layout '/foo.erb'
     # layout '/foo.erb', params
 
diff --git a/lib/nanoc/base/entities/processing_actions/snapshot.rb b/lib/nanoc/base/entities/processing_actions/snapshot.rb
new file mode 100644
index 0000000..dd171fd
--- /dev/null
+++ b/lib/nanoc/base/entities/processing_actions/snapshot.rb
@@ -0,0 +1,28 @@
+module Nanoc::Int::ProcessingActions
+  class Snapshot < Nanoc::Int::ProcessingAction
+    # snapshot :before_layout
+    # snapshot :before_layout, path: '/about.md'
+
+    attr_reader :snapshot_name
+    attr_reader :path
+
+    def initialize(snapshot_name, path)
+      @snapshot_name = snapshot_name
+      @path = path
+    end
+
+    def serialize
+      [:snapshot, @snapshot_name, true, @path]
+    end
+
+    NONE = Object.new
+
+    def copy(path: NONE)
+      self.class.new(@snapshot_name, path.equal?(NONE) ? @path : path)
+    end
+
+    def to_s
+      "snapshot #{@snapshot_name.inspect}, path: #{@path.inspect}"
+    end
+  end
+end
diff --git a/lib/nanoc/base/entities/props.rb b/lib/nanoc/base/entities/props.rb
new file mode 100644
index 0000000..e4e0845
--- /dev/null
+++ b/lib/nanoc/base/entities/props.rb
@@ -0,0 +1,76 @@
+module Nanoc::Int
+  # @api private
+  class Props
+    include Nanoc::Int::ContractsSupport
+
+    contract C::KeywordArgs[raw_content: C::Optional[C::Bool], attributes: C::Optional[C::Bool], compiled_content: C::Optional[C::Bool], path: C::Optional[C::Bool]] => C::Any
+    def initialize(raw_content: false, attributes: false, compiled_content: false, path: false)
+      @raw_content = raw_content
+      @attributes = attributes
+      @compiled_content = compiled_content
+      @path = path
+    end
+
+    contract C::None => String
+    def inspect
+      ''.tap do |s|
+        s << 'Props('
+        s << (raw_content? ? 'r' : '_')
+        s << (attributes? ? 'a' : '_')
+        s << (compiled_content? ? 'c' : '_')
+        s << (path? ? 'p' : '_')
+        s << ')'
+      end
+    end
+
+    contract C::None => C::Bool
+    def raw_content?
+      @raw_content
+    end
+
+    contract C::None => C::Bool
+    def attributes?
+      @attributes
+    end
+
+    contract C::None => C::Bool
+    def compiled_content?
+      @compiled_content
+    end
+
+    contract C::None => C::Bool
+    def path?
+      @path
+    end
+
+    contract Nanoc::Int::Props => Nanoc::Int::Props
+    def merge(other)
+      Props.new(
+        raw_content: raw_content? || other.raw_content?,
+        attributes: attributes? || other.attributes?,
+        compiled_content: compiled_content? || other.compiled_content?,
+        path: path? || other.path?,
+      )
+    end
+
+    contract C::None => Set
+    def active
+      Set.new.tap do |pr|
+        pr << :raw_content if raw_content?
+        pr << :attributes if attributes?
+        pr << :compiled_content if compiled_content?
+        pr << :path if path?
+      end
+    end
+
+    contract C::None => Hash
+    def to_h
+      {
+        raw_content: raw_content?,
+        attributes: attributes?,
+        compiled_content: compiled_content?,
+        path: path?,
+      }
+    end
+  end
+end
diff --git a/lib/nanoc/base/entities/rule_memory.rb b/lib/nanoc/base/entities/rule_memory.rb
index 74aa973..6618b06 100644
--- a/lib/nanoc/base/entities/rule_memory.rb
+++ b/lib/nanoc/base/entities/rule_memory.rb
@@ -3,9 +3,9 @@ module Nanoc::Int
     include Nanoc::Int::ContractsSupport
     include Enumerable
 
-    def initialize(item_rep)
+    def initialize(item_rep, actions: [])
       @item_rep = item_rep
-      @actions = []
+      @actions = actions
     end
 
     contract C::None => Numeric
@@ -13,51 +13,66 @@ module Nanoc::Int
       @actions.size
     end
 
-    contract Numeric => C::Maybe[Nanoc::Int::RuleMemoryAction]
+    contract Numeric => C::Maybe[Nanoc::Int::ProcessingAction]
     def [](idx)
       @actions[idx]
     end
 
     contract Symbol, Hash => self
     def add_filter(filter_name, params)
-      @actions << Nanoc::Int::RuleMemoryActions::Filter.new(filter_name, params)
+      @actions << Nanoc::Int::ProcessingActions::Filter.new(filter_name, params)
       self
     end
 
     contract String, C::Maybe[Hash] => self
     def add_layout(layout_identifier, params)
-      @actions << Nanoc::Int::RuleMemoryActions::Layout.new(layout_identifier, params)
+      @actions << Nanoc::Int::ProcessingActions::Layout.new(layout_identifier, params)
       self
     end
 
-    contract Symbol, C::Bool, C::Maybe[String] => self
-    def add_snapshot(snapshot_name, final, path)
-      will_add_snapshot(snapshot_name) if final
-      @actions << Nanoc::Int::RuleMemoryActions::Snapshot.new(snapshot_name, final, path)
+    contract Symbol, C::Maybe[String] => self
+    def add_snapshot(snapshot_name, path)
+      will_add_snapshot(snapshot_name)
+      @actions << Nanoc::Int::ProcessingActions::Snapshot.new(snapshot_name, path)
       self
     end
 
-    contract C::None => C::ArrayOf[Nanoc::Int::RuleMemoryAction]
+    contract C::None => C::ArrayOf[Nanoc::Int::ProcessingAction]
     def snapshot_actions
-      @actions.select { |a| a.is_a?(Nanoc::Int::RuleMemoryActions::Snapshot) }
+      @actions.select { |a| a.is_a?(Nanoc::Int::ProcessingActions::Snapshot) }
     end
 
     contract C::None => C::Bool
     def any_layouts?
-      @actions.any? { |a| a.is_a?(Nanoc::Int::RuleMemoryActions::Layout) }
+      @actions.any? { |a| a.is_a?(Nanoc::Int::ProcessingActions::Layout) }
+    end
+
+    contract C::None => Hash
+    def paths
+      snapshot_actions.each_with_object({}) do |action, paths|
+        paths[action.snapshot_name] = action.path
+      end
     end
 
     # TODO: Add contract
     def serialize
-      map(&:serialize)
+      to_a.map(&:serialize)
     end
 
-    contract C::Func[Nanoc::Int::RuleMemoryAction => C::Any] => self
+    contract C::Func[Nanoc::Int::ProcessingAction => C::Any] => self
     def each
       @actions.each { |a| yield(a) }
       self
     end
 
+    contract C::Func[Nanoc::Int::ProcessingAction => C::Any] => self
+    def map
+      self.class.new(
+        @item_rep,
+        actions: @actions.map { |a| yield(a) },
+      )
+    end
+
     private
 
     def will_add_snapshot(name)
diff --git a/lib/nanoc/base/entities/rule_memory_actions.rb b/lib/nanoc/base/entities/rule_memory_actions.rb
deleted file mode 100644
index 483ea36..0000000
--- a/lib/nanoc/base/entities/rule_memory_actions.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-require_relative 'rule_memory_actions/filter'
-require_relative 'rule_memory_actions/layout'
-require_relative 'rule_memory_actions/snapshot'
diff --git a/lib/nanoc/base/entities/rule_memory_actions/snapshot.rb b/lib/nanoc/base/entities/rule_memory_actions/snapshot.rb
deleted file mode 100644
index 230707e..0000000
--- a/lib/nanoc/base/entities/rule_memory_actions/snapshot.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-module Nanoc::Int::RuleMemoryActions
-  class Snapshot < Nanoc::Int::RuleMemoryAction
-    # snapshot :before_layout
-    # snapshot :before_layout, final: true
-    # snapshot :before_layout, path: '/about.md'
-
-    attr_reader :snapshot_name
-    attr_reader :final
-    attr_reader :path
-    alias final? final
-
-    def initialize(snapshot_name, final, path)
-      @snapshot_name = snapshot_name
-      @final = final
-      @path = path
-    end
-
-    def serialize
-      [:snapshot, @snapshot_name, @final, @path]
-    end
-
-    def to_s
-      "snapshot #{@snapshot_name.inspect}, final: #{@final.inspect}, path: #{@path.inspect}"
-    end
-  end
-end
diff --git a/lib/nanoc/base/entities/site.rb b/lib/nanoc/base/entities/site.rb
index e93806c..1358aeb 100644
--- a/lib/nanoc/base/entities/site.rb
+++ b/lib/nanoc/base/entities/site.rb
@@ -5,11 +5,7 @@ module Nanoc::Int
 
     attr_accessor :compiler
 
-    contract C::KeywordArgs[config: Nanoc::Int::Configuration, code_snippets: C::RespondTo[:each], items: C::RespondTo[:each], layouts: C::RespondTo[:each]] => C::Any
-    # @param [Nanoc::Int::Configuration] config
-    # @param [Enumerable<Nanoc::Int::CodeSnippet>] code_snippets
-    # @param [Enumerable<Nanoc::Int::Item>] items
-    # @param [Enumerable<Nanoc::Int::Layout>] layouts
+    contract C::KeywordArgs[config: Nanoc::Int::Configuration, code_snippets: C::IterOf[Nanoc::Int::CodeSnippet], items: C::IterOf[Nanoc::Int::Item], layouts: C::IterOf[Nanoc::Int::Layout]] => C::Any
     def initialize(config:, code_snippets:, items:, layouts:)
       @config = config
       @code_snippets = code_snippets
@@ -21,21 +17,12 @@ module Nanoc::Int
     end
 
     contract C::None => self
-    # Compiles the site.
-    #
-    # @return [void]
-    #
-    # @since 3.2.0
     def compile
       compiler.run_all
       self
     end
 
-    contract C::None => Nanoc::Int::Compiler
-    # Returns the compiler for this site. Will create a new compiler if none
-    # exists yet.
-    #
-    # @return [Nanoc::Int::Compiler] The compiler for this site
+    contract C::None => C::Named['Nanoc::Int::Compiler']
     def compiler
       @compiler ||= Nanoc::Int::CompilerLoader.new.load(self)
     end
@@ -46,9 +33,6 @@ module Nanoc::Int
     attr_reader :layouts
 
     contract C::None => self
-    # Prevents all further modifications to itself, its items, its layouts etc.
-    #
-    # @return [void]
     def freeze
       config.freeze
       items.freeze
@@ -57,7 +41,7 @@ module Nanoc::Int
       self
     end
 
-    contract C::RespondTo[:each], String => self
+    contract C::IterOf[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]], String => self
     def ensure_identifier_uniqueness(objects, type)
       seen = Set.new
       objects.each do |obj|
diff --git a/lib/nanoc/base/entities/snapshot_def.rb b/lib/nanoc/base/entities/snapshot_def.rb
index 2af7177..9f05d71 100644
--- a/lib/nanoc/base/entities/snapshot_def.rb
+++ b/lib/nanoc/base/entities/snapshot_def.rb
@@ -5,15 +5,9 @@ module Nanoc
 
       attr_reader :name
 
-      contract Symbol, C::Bool => C::Any
-      def initialize(name, is_final)
+      contract Symbol => C::Any
+      def initialize(name)
         @name = name
-        @is_final = is_final
-      end
-
-      contract C::None => C::Bool
-      def final?
-        @is_final
       end
     end
   end
diff --git a/lib/nanoc/base/errors.rb b/lib/nanoc/base/errors.rb
index 88ceee2..d1002fa 100644
--- a/lib/nanoc/base/errors.rb
+++ b/lib/nanoc/base/errors.rb
@@ -10,6 +10,21 @@ module Nanoc::Int
     class GenericTrivial < Generic
     end
 
+    # Error that is raised when compilation of an item rep fails. The
+    # underlying error is available by calling `#unwrap`.
+    class CompilationError < Generic
+      attr_reader :item_rep
+
+      def initialize(wrapped, item_rep)
+        @wrapped = wrapped
+        @item_rep = item_rep
+      end
+
+      def unwrap
+        @wrapped
+      end
+    end
+
     # Error that is raised when a site is loaded that uses a data source with
     # an unknown identifier.
     class UnknownDataSource < Generic
@@ -192,5 +207,14 @@ module Nanoc::Int
         super("You cannot get the parent or children of an item that has a “full” identifier (#{identifier}). Getting the parent or children of an item is only possible for items that have a legacy identifier.")
       end
     end
+
+    class UndefinedFilterForLayout < Generic
+      def initialize(layout)
+        super("There is no filter defined for the layout #{layout.identifier}")
+      end
+    end
+
+    class InternalInconsistency < Generic
+    end
   end
 end
diff --git a/lib/nanoc/base/feature.rb b/lib/nanoc/base/feature.rb
index 6d63be7..b3bc0ed 100644
--- a/lib/nanoc/base/feature.rb
+++ b/lib/nanoc/base/feature.rb
@@ -1,19 +1,92 @@
 module Nanoc
   # @api private
+  #
+  # @example Defining a feature and checking its enabledness
+  #
+  #     Nanoc::Feature.define('environments', version: '4.3')
+  #     Nanoc::Feaure.enabled?(Nanoc::Feature::ENVIRONMENTS)
+  #
   module Feature
-    PROFILER = 'profiler'.freeze
+    # Defines a new feature with the given name, experimental in the given
+    # version. The feature will be made available as a constant with the same
+    # name, in uppercase, on the Nanoc::Feature module.
+    #
+    # @example Defining Nanoc::Feature::ENVIRONMENTS
+    #
+    #     Nanoc::Feature.define('environments', version: '4.3')
+    #
+    # @param name The name of the feature
+    #
+    # @param version The minor version in which the feature is considered
+    #   experimental.
+    #
+    # @return [void]
+    def self.define(name, version:)
+      repo[name] = version
+      const_set(name.upcase, name)
+    end
 
-    def self.enabled_features
-      @enabled_features ||= Set.new(ENV.fetch('NANOC_FEATURES', '').split(','))
+    # Undefines the feature with the given name. For testing purposes only.
+    #
+    # @param name The name of the feature
+    #
+    # @return [void]
+    #
+    # @private
+    def self.undefine(name)
+      repo.delete(name)
+      remove_const(name.upcase)
     end
 
+    # @param [String] feature_name
+    #
+    # @return [Boolean] Whether or not the feature with the given name is enabled
     def self.enabled?(feature_name)
       enabled_features.include?(feature_name) ||
         enabled_features.include?('all')
     end
 
+    # @api private
+    def self.enable(feature_name)
+      raise ArgumentError, 'no block given' unless block_given?
+
+      if enabled?(feature_name)
+        yield
+      else
+        begin
+          enabled_features << feature_name
+          yield
+        ensure
+          enabled_features.delete(feature_name)
+        end
+      end
+    end
+
+    # @api private
     def self.reset_caches
       @enabled_features = nil
     end
+
+    # @api private
+    def self.enabled_features
+      @enabled_features ||= Set.new(ENV.fetch('NANOC_FEATURES', '').split(','))
+    end
+
+    # @api private
+    def self.repo
+      @repo ||= {}
+    end
+
+    # @return [Enumerable<String>] Names of features that still exist, but
+    #   should not be considered as experimental in the current version of
+    #   Nanoc.
+    def self.all_outdated
+      repo.keys.reject do |name|
+        version = repo[name]
+        Nanoc::VERSION.start_with?(version)
+      end
+    end
   end
 end
+
+Nanoc::Feature.define('profiler', version: '4.4')
diff --git a/lib/nanoc/base/memoization.rb b/lib/nanoc/base/memoization.rb
index 156d616..d21edc5 100644
--- a/lib/nanoc/base/memoization.rb
+++ b/lib/nanoc/base/memoization.rb
@@ -4,8 +4,6 @@ module Nanoc::Int
   # Adds support for memoizing functions.
   #
   # @api private
-  #
-  # @since 3.2.0
   module Memoization
     class Wrapper
       attr_reader :ref
diff --git a/lib/nanoc/base/plugin_registry.rb b/lib/nanoc/base/plugin_registry.rb
index 84f18e3..2463bd4 100644
--- a/lib/nanoc/base/plugin_registry.rb
+++ b/lib/nanoc/base/plugin_registry.rb
@@ -157,7 +157,7 @@ module Nanoc::Int
       res
     end
 
-    # @param [Class] klass
+    # @param [Class] subclass
     #
     # @return [Class]
     #
diff --git a/lib/nanoc/base/repos.rb b/lib/nanoc/base/repos.rb
index 331579e..8d1b299 100644
--- a/lib/nanoc/base/repos.rb
+++ b/lib/nanoc/base/repos.rb
@@ -5,5 +5,6 @@ require_relative 'repos/compiled_content_cache'
 require_relative 'repos/config_loader'
 require_relative 'repos/data_source'
 require_relative 'repos/dependency_store'
+require_relative 'repos/item_rep_repo'
 require_relative 'repos/rule_memory_store'
 require_relative 'repos/site_loader'
diff --git a/lib/nanoc/base/repos/checksum_store.rb b/lib/nanoc/base/repos/checksum_store.rb
index 5c590d2..123ffdc 100644
--- a/lib/nanoc/base/repos/checksum_store.rb
+++ b/lib/nanoc/base/repos/checksum_store.rb
@@ -4,32 +4,46 @@ module Nanoc::Int
   #
   # @api private
   class ChecksumStore < ::Nanoc::Int::Store
-    # @param [Nanoc::Int::Site] site
-    def initialize(site: nil)
-      super('tmp/checksums', 1)
+    include Nanoc::Int::ContractsSupport
 
-      @site = site
+    attr_accessor :objects
+
+    c_obj = C::Or[Nanoc::Int::Item, Nanoc::Int::Layout, Nanoc::Int::Configuration, Nanoc::Int::CodeSnippet]
+
+    contract C::KeywordArgs[site: C::Maybe[Nanoc::Int::Site], objects: C::IterOf[c_obj]] => C::Any
+    def initialize(site: nil, objects:)
+      super(Nanoc::Int::Store.tmp_path_for(env_name: (site.config.env_name if site), store_name: 'checksums'), 1)
+
+      @objects = objects
 
       @checksums = {}
     end
 
-    # Returns the old checksum for the given object. This makes sense for
-    # items, layouts and code snippets.
-    #
-    # @param [#reference] obj The object for which to fetch the checksum
-    #
-    # @return [String] The checksum for the given object
+    contract c_obj => C::Maybe[String]
     def [](obj)
       @checksums[obj.reference]
     end
 
-    # Sets the checksum for the given object.
-    #
-    # @param [#reference] obj The object for which to set the checksum
-    #
-    # @param [String] checksum The checksum
-    def []=(obj, checksum)
-      @checksums[obj.reference] = checksum
+    contract c_obj => self
+    def add(obj)
+      if obj.is_a?(Nanoc::Int::Document)
+        @checksums[[obj.reference, :content]] = Nanoc::Int::Checksummer.calc_for_content_of(obj)
+        @checksums[[obj.reference, :attributes]] = Nanoc::Int::Checksummer.calc_for_attributes_of(obj)
+      end
+
+      @checksums[obj.reference] = Nanoc::Int::Checksummer.calc(obj)
+
+      self
+    end
+
+    contract c_obj => C::Maybe[String]
+    def content_checksum_for(obj)
+      @checksums[[obj.reference, :content]]
+    end
+
+    contract c_obj => C::Maybe[String]
+    def attributes_checksum_for(obj)
+      @checksums[[obj.reference, :attributes]]
     end
 
     protected
@@ -39,7 +53,14 @@ module Nanoc::Int
     end
 
     def data=(new_data)
-      @checksums = new_data
+      references = Set.new(@objects.map(&:reference))
+
+      @checksums = {}
+      new_data.each_pair do |key, checksum|
+        if references.include?(key) || references.include?(key.first)
+          @checksums[key] = checksum
+        end
+      end
     end
   end
 end
diff --git a/lib/nanoc/base/repos/compiled_content_cache.rb b/lib/nanoc/base/repos/compiled_content_cache.rb
index e753d09..1a35918 100644
--- a/lib/nanoc/base/repos/compiled_content_cache.rb
+++ b/lib/nanoc/base/repos/compiled_content_cache.rb
@@ -4,38 +4,35 @@ module Nanoc::Int
   #
   # @api private
   class CompiledContentCache < ::Nanoc::Int::Store
-    def initialize
-      super('tmp/compiled_content', 2)
+    include Nanoc::Int::ContractsSupport
 
+    contract C::KeywordArgs[env_name: C::Maybe[String], items: C::IterOf[Nanoc::Int::Item]] => C::Any
+    def initialize(env_name: nil, items:)
+      super(Nanoc::Int::Store.tmp_path_for(env_name: env_name, store_name: 'compiled_content'), 2)
+
+      @items = items
       @cache = {}
     end
 
-    # Returns the cached compiled content for the given item
-    # representation. This cached compiled content is a hash where the keys
-    # are the snapshot names and the values the compiled content at the
-    # given snapshot.
-    #
-    # @param [Nanoc::Int::ItemRep] rep The item rep to fetch the content for
+    contract Nanoc::Int::ItemRep => C::Maybe[C::HashOf[Symbol => Nanoc::Int::Content]]
+    # Returns the cached compiled content for the given item representation.
     #
-    # @return [Hash<Symbol,String>] A hash containing the cached compiled
-    #   content for the given item representation
+    # This cached compiled content is a hash where the keys are the snapshot
+    # names. and the values the compiled content at the given snapshot.
     def [](rep)
       item_cache = @cache[rep.item.identifier] || {}
       item_cache[rep.name]
     end
 
+    contract Nanoc::Int::ItemRep, C::HashOf[Symbol => Nanoc::Int::Content] => self
     # Sets the compiled content for the given representation.
     #
-    # @param [Nanoc::Int::ItemRep] rep The item representation for which to set
-    #   the compiled content
-    #
-    # @param [Hash<Symbol,String>] content A hash containing the compiled
-    #   content of the given representation
-    #
-    # @return [void]
+    # This cached compiled content is a hash where the keys are the snapshot
+    # names. and the values the compiled content at the given snapshot.
     def []=(rep, content)
       @cache[rep.item.identifier] ||= {}
       @cache[rep.item.identifier][rep.name] = content
+      self
     end
 
     protected
@@ -45,7 +42,15 @@ module Nanoc::Int
     end
 
     def data=(new_data)
-      @cache = new_data
+      @cache = {}
+
+      item_identifiers = Set.new(@items.map(&:identifier))
+
+      new_data.each_pair do |item_identifier, content_per_rep|
+        if item_identifiers.include?(item_identifier)
+          @cache[item_identifier] ||= content_per_rep
+        end
+      end
     end
   end
 end
diff --git a/lib/nanoc/base/repos/config_loader.rb b/lib/nanoc/base/repos/config_loader.rb
index 8fa18de..5a68ef9 100644
--- a/lib/nanoc/base/repos/config_loader.rb
+++ b/lib/nanoc/base/repos/config_loader.rb
@@ -37,10 +37,14 @@ module Nanoc::Int
       raise NoConfigFileFoundError if filename.nil?
 
       # Read
-      apply_parent_config(
-        Nanoc::Int::Configuration.new(YAML.load_file(filename)),
-        [filename],
-      ).with_defaults
+      config =
+        apply_parent_config(
+          Nanoc::Int::Configuration.new(hash: YAML.load_file(filename)),
+          [filename],
+        ).with_defaults
+
+      # Load environment
+      config.with_environment
     end
 
     # @api private
@@ -60,7 +64,7 @@ module Nanoc::Int
       end
 
       # Load
-      parent_config = Nanoc::Int::Configuration.new(YAML.load_file(parent_path))
+      parent_config = Nanoc::Int::Configuration.new(hash: YAML.load_file(parent_path))
       full_parent_config = apply_parent_config(parent_config, processed_paths + [parent_path])
       full_parent_config.merge(config.without(:parent_config_file))
     end
diff --git a/lib/nanoc/base/repos/data_source.rb b/lib/nanoc/base/repos/data_source.rb
index b959f01..08c87d9 100644
--- a/lib/nanoc/base/repos/data_source.rb
+++ b/lib/nanoc/base/repos/data_source.rb
@@ -85,8 +85,7 @@ module Nanoc
     # default implementation simply does nothing.
     #
     # @return [void]
-    def up
-    end
+    def up; end
 
     # Brings down the connection to the data. This method should undo the
     # effects of the {#up} method. For example, a database connection
@@ -96,8 +95,7 @@ module Nanoc
     # default implementation simply does nothing.
     #
     # @return [void]
-    def down
-    end
+    def down; end
 
     # Returns the collection of items (represented by {Nanoc::Int::Item}) in
     # this site. The default implementation simply returns an empty array.
@@ -140,10 +138,14 @@ module Nanoc
     #
     # @param [Boolean] binary Whether or not this item is binary
     #
-    # @param [String, nil] checksum_data Used to determine whether the item has changed
-    def new_item(content, attributes, identifier, binary: false, checksum_data: nil)
+    # @param [String, nil] checksum_data
+    #
+    # @param [String, nil] content_checksum_data
+    #
+    # @param [String, nil] attributes_checksum_data
+    def new_item(content, attributes, identifier, binary: false, checksum_data: nil, content_checksum_data: nil, attributes_checksum_data: nil)
       content = Nanoc::Int::Content.create(content, binary: binary)
-      Nanoc::Int::Item.new(content, attributes, identifier, checksum_data: checksum_data)
+      Nanoc::Int::Item.new(content, attributes, identifier, checksum_data: checksum_data, content_checksum_data: content_checksum_data, attributes_checksum_data: attributes_checksum_data)
     end
 
     # Creates a new in-memory layout instance. This is intended for use within
@@ -155,9 +157,13 @@ module Nanoc
     #
     # @param [String] identifier This layout's identifier.
     #
-    # @param [String, nil] checksum_data Used to determine whether the layout has changed
-    def new_layout(raw_content, attributes, identifier, checksum_data: nil)
-      Nanoc::Int::Layout.new(raw_content, attributes, identifier, checksum_data: checksum_data)
+    # @param [String, nil] checksum_data
+    #
+    # @param [String, nil] content_checksum_data
+    #
+    # @param [String, nil] attributes_checksum_data
+    def new_layout(raw_content, attributes, identifier, checksum_data: nil, content_checksum_data: nil, attributes_checksum_data: nil)
+      Nanoc::Int::Layout.new(raw_content, attributes, identifier, checksum_data: checksum_data, content_checksum_data: content_checksum_data, attributes_checksum_data: attributes_checksum_data)
     end
   end
 end
diff --git a/lib/nanoc/base/repos/dependency_store.rb b/lib/nanoc/base/repos/dependency_store.rb
index 7114e79..62318e4 100644
--- a/lib/nanoc/base/repos/dependency_store.rb
+++ b/lib/nanoc/base/repos/dependency_store.rb
@@ -1,15 +1,36 @@
 module Nanoc::Int
   # @api private
   class DependencyStore < ::Nanoc::Int::Store
+    include Nanoc::Int::ContractsSupport
+
     # @return [Array<Nanoc::Int::Item, Nanoc::Int::Layout>]
     attr_accessor :objects
 
     # @param [Array<Nanoc::Int::Item, Nanoc::Int::Layout>] objects
-    def initialize(objects)
-      super('tmp/dependencies', 4)
+    def initialize(objects, env_name: nil)
+      super(Nanoc::Int::Store.tmp_path_for(env_name: env_name, store_name: 'dependencies'), 4)
 
       @objects = objects
-      @graph   = Nanoc::Int::DirectedGraph.new([nil] + @objects)
+      @new_objects = []
+      @graph = Nanoc::Int::DirectedGraph.new([nil] + @objects)
+    end
+
+    contract C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] => C::ArrayOf[Nanoc::Int::Dependency]
+    def dependencies_causing_outdatedness_of(object)
+      objects_causing_outdatedness_of(object).map do |other_object|
+        props = props_for(other_object, object)
+
+        Nanoc::Int::Dependency.new(
+          other_object,
+          object,
+          Nanoc::Int::Props.new(
+            raw_content: props.fetch(:raw_content, false),
+            attributes: props.fetch(:attributes, false),
+            compiled_content: props.fetch(:compiled_content, false),
+            path: props.fetch(:path, false),
+          ),
+        )
+      end
     end
 
     # Returns the direct dependencies for the given object.
@@ -30,26 +51,14 @@ module Nanoc::Int
     # predecessors of
     #   the given object
     def objects_causing_outdatedness_of(object)
-      @graph.direct_predecessors_of(object)
-    end
-
-    # Returns the direct inverse dependencies for the given object.
-    #
-    # The direct inverse dependencies of the given object include the objects
-    # that will be marked as outdated when the given object is outdated.
-    # Indirect dependencies will not be returned (e.g. if A depends on B which
-    # depends on C, then the direct inverse dependencies of C do not include
-    # A).
-    #
-    # @param [Nanoc::Int::Item, Nanoc::Int::Layout] object The object for which to
-    #   fetch the direct successors
-    #
-    # @return [Array<Nanoc::Int::Item, Nanoc::Int::Layout>] The direct successors of
-    #   the given object
-    def objects_outdated_due_to(object)
-      @graph.direct_successors_of(object).compact
+      if @new_objects.any?
+        [@new_objects.first]
+      else
+        @graph.direct_predecessors_of(object)
+      end
     end
 
+    contract C::Maybe[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]], C::Maybe[C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]], C::KeywordArgs[raw_content: C::Optional[C::Bool], attributes: C::Optional[C::Bool], compiled_content: C::Optional[C::Bool], path: C::Optional[C::Bool]] => C::Any
     # Records a dependency from `src` to `dst` in the dependency graph. When
     # `dst` is oudated, `src` will also become outdated.
     #
@@ -61,9 +70,13 @@ module Nanoc::Int
     #   outdated if the destination is outdated
     #
     # @return [void]
-    def record_dependency(src, dst)
+    def record_dependency(src, dst, raw_content: false, attributes: false, compiled_content: false, path: false)
+      existing_props = Nanoc::Int::Props.new(@graph.props_for(dst, src) || {})
+      new_props = Nanoc::Int::Props.new(raw_content: raw_content, attributes: attributes, compiled_content: compiled_content, path: path)
+      props = existing_props.merge(new_props)
+
       # Warning! dst and src are *reversed* here!
-      @graph.add_edge(dst, src) unless src == dst
+      @graph.add_edge(dst, src, props: props.to_h) unless src == dst
     end
 
     # Empties the list of dependencies for the given object. This is necessary
@@ -81,6 +94,16 @@ module Nanoc::Int
 
     protected
 
+    def props_for(a, b)
+      props = @graph.props_for(a, b) || {}
+
+      if props.values.any? { |v| v }
+        props
+      else
+        { raw_content: true, attributes: true, compiled_content: true, path: true }
+      end
+    end
+
     def data
       {
         edges: @graph.edges,
@@ -99,20 +122,14 @@ module Nanoc::Int
 
       # Load edges
       new_data[:edges].each do |edge|
-        from_index, to_index = *edge
+        from_index, to_index, props = *edge
         from = from_index && previous_objects[from_index]
         to   = to_index && previous_objects[to_index]
-        @graph.add_edge(from, to)
+        @graph.add_edge(from, to, props: props)
       end
 
       # Record dependency from all items on new items
-      new_objects = (@objects - previous_objects)
-      new_objects.each do |new_obj|
-        @objects.each do |obj|
-          next unless obj.is_a?(Nanoc::Int::Item)
-          @graph.add_edge(new_obj, obj)
-        end
-      end
+      @new_objects = @objects - previous_objects
     end
   end
 end
diff --git a/lib/nanoc/base/compilation/item_rep_repo.rb b/lib/nanoc/base/repos/item_rep_repo.rb
similarity index 100%
rename from lib/nanoc/base/compilation/item_rep_repo.rb
rename to lib/nanoc/base/repos/item_rep_repo.rb
diff --git a/lib/nanoc/base/repos/rule_memory_store.rb b/lib/nanoc/base/repos/rule_memory_store.rb
index e44fc40..53f3025 100644
--- a/lib/nanoc/base/repos/rule_memory_store.rb
+++ b/lib/nanoc/base/repos/rule_memory_store.rb
@@ -4,8 +4,8 @@ module Nanoc::Int
   #
   # @api private
   class RuleMemoryStore < ::Nanoc::Int::Store
-    def initialize
-      super('tmp/rule_memory', 1)
+    def initialize(env_name: nil)
+      super(Nanoc::Int::Store.tmp_path_for(env_name: env_name, store_name: 'rule_memory'), 1)
 
       @rule_memories = {}
     end
diff --git a/lib/nanoc/base/repos/site_loader.rb b/lib/nanoc/base/repos/site_loader.rb
index 5c0cb61..6772e96 100644
--- a/lib/nanoc/base/repos/site_loader.rb
+++ b/lib/nanoc/base/repos/site_loader.rb
@@ -5,7 +5,7 @@ module Nanoc::Int
     end
 
     def new_with_config(hash)
-      site_from_config(Nanoc::Int::Configuration.new(hash).with_defaults)
+      site_from_config(Nanoc::Int::Configuration.new(hash: hash).with_defaults)
     end
 
     def new_from_cwd
diff --git a/lib/nanoc/base/repos/store.rb b/lib/nanoc/base/repos/store.rb
index 17f8c0f..c8035ba 100644
--- a/lib/nanoc/base/repos/store.rb
+++ b/lib/nanoc/base/repos/store.rb
@@ -12,6 +12,8 @@ module Nanoc::Int
   #
   # @api private
   class Store
+    include Nanoc::Int::ContractsSupport
+
     # @return [String] The name of the file where data will be loaded from and
     #   stored to.
     attr_reader :filename
@@ -34,6 +36,13 @@ module Nanoc::Int
       @version  = version
     end
 
+    # Logic for building tmp path from active environment and store name
+    # @api private
+    contract C::KeywordArgs[env_name: C::Maybe[String], store_name: String] => String
+    def self.tmp_path_for(env_name:, store_name:)
+      File.join('tmp', env_name.to_s, store_name)
+    end
+
     # @group Loading and storing data
 
     # @return The data that should be written to the disk
@@ -57,21 +66,11 @@ module Nanoc::Int
     #
     # @return [void]
     def load
-      # Check file existance
-      unless File.file?(filename)
-        no_data_found
-        return
-      end
+      return unless File.file?(filename)
 
       begin
         pstore.transaction do
-          # Check version
-          if pstore[:version] != version
-            version_mismatch_detected
-            return
-          end
-
-          # Load
+          return if pstore[:version] != version
           self.data = pstore[:data]
         end
       rescue
@@ -93,24 +92,6 @@ module Nanoc::Int
       end
     end
 
-    # @group Callback methods
-
-    # Callback method that is called when no data file was found. By default,
-    # this implementation does nothing, but it should probably be overridden
-    # by the subclass.
-    #
-    # @return [void]
-    def no_data_found
-    end
-
-    # Callback method that is called when a version mismatch is detected. By
-    # default, this implementation does nothing, but it should probably be
-    # overridden by the subclass.
-    #
-    # @return [void]
-    def version_mismatch_detected
-    end
-
     private
 
     def pstore
diff --git a/lib/nanoc/base/services.rb b/lib/nanoc/base/services.rb
index 6c3bb8a..05f865b 100644
--- a/lib/nanoc/base/services.rb
+++ b/lib/nanoc/base/services.rb
@@ -1,9 +1,18 @@
 require_relative 'services/action_provider'
+require_relative 'services/checksummer'
+require_relative 'services/compiler'
 require_relative 'services/compiler_loader'
+require_relative 'services/dependency_tracker'
 require_relative 'services/executor'
+require_relative 'services/filter'
 require_relative 'services/item_rep_builder'
 require_relative 'services/item_rep_router'
 require_relative 'services/item_rep_selector'
 require_relative 'services/item_rep_writer'
 require_relative 'services/notification_center'
+require_relative 'services/pruner'
 require_relative 'services/temp_filename_factory'
+require_relative 'services/outdatedness_rule'
+require_relative 'services/outdatedness_rules'
+
+require_relative 'services/outdatedness_checker'
diff --git a/lib/nanoc/base/services/action_provider.rb b/lib/nanoc/base/services/action_provider.rb
index 6a127d2..92e1483 100644
--- a/lib/nanoc/base/services/action_provider.rb
+++ b/lib/nanoc/base/services/action_provider.rb
@@ -18,5 +18,9 @@ module Nanoc::Int
     def snapshots_defs_for(_rep)
       raise NotImplementedError
     end
+
+    def paths_for(rep)
+      memory_for(rep).paths
+    end
   end
 end
diff --git a/lib/nanoc/base/checksummer.rb b/lib/nanoc/base/services/checksummer.rb
similarity index 71%
rename from lib/nanoc/base/checksummer.rb
rename to lib/nanoc/base/services/checksummer.rb
index adf7b09..ebc572a 100644
--- a/lib/nanoc/base/checksummer.rb
+++ b/lib/nanoc/base/services/checksummer.rb
@@ -44,6 +44,14 @@ module Nanoc::Int
         digest.to_s
       end
 
+      def calc_for_content_of(obj)
+        obj.content_checksum_data || obj.checksum_data || Nanoc::Int::Checksummer.calc(obj.content)
+      end
+
+      def calc_for_attributes_of(obj)
+        obj.attributes_checksum_data || obj.checksum_data || Nanoc::Int::Checksummer.calc(obj.attributes)
+      end
+
       private
 
       def update(obj, digest, visited = Hamster::Set.new)
@@ -72,6 +80,8 @@ module Nanoc::Int
           HashUpdateBehavior
         when Nanoc::Int::Item, Nanoc::Int::Layout
           DocumentUpdateBehavior
+        when Nanoc::Int::ItemRep
+          ItemRepUpdateBehavior
         when NilClass, TrueClass, FalseClass
           NoUpdateBehavior
         when Time
@@ -84,6 +94,10 @@ module Nanoc::Int
           StringUpdateBehavior
         when Nanoc::View
           UnwrapUpdateBehavior
+        when Nanoc::RuleDSL::RuleContext
+          RuleContextUpdateBehavior
+        when Nanoc::Int::Context
+          ContextUpdateBehavior
         else
           RescueUpdateBehavior
         end
@@ -96,6 +110,32 @@ module Nanoc::Int
       end
     end
 
+    class RuleContextUpdateBehavior < UpdateBehavior
+      def self.update(obj, digest)
+        digest.update('item=')
+        yield(obj.item)
+        digest.update(',rep=')
+        yield(obj.rep)
+        digest.update(',items=')
+        yield(obj.items)
+        digest.update(',layouts=')
+        yield(obj.layouts)
+        digest.update(',config=')
+        yield(obj.config)
+      end
+    end
+
+    class ContextUpdateBehavior < UpdateBehavior
+      def self.update(obj, digest)
+        obj.instance_variables.each do |var|
+          digest.update(var.to_s)
+          digest.update('=')
+          yield(obj.instance_variable_get(var))
+          digest.update(',')
+        end
+      end
+    end
+
     class RawUpdateBehavior < UpdateBehavior
       def self.update(obj, digest)
         digest.update(obj.to_s)
@@ -127,8 +167,7 @@ module Nanoc::Int
     end
 
     class NoUpdateBehavior < UpdateBehavior
-      def self.update(_obj, _digest)
-      end
+      def self.update(_obj, _digest); end
     end
 
     class UnwrapUpdateBehavior < UpdateBehavior
@@ -162,11 +201,19 @@ module Nanoc::Int
         if obj.checksum_data
           digest.update('checksum_data=' + obj.checksum_data)
         else
-          digest.update('content=')
-          yield(obj.content)
+          if obj.content_checksum_data
+            digest.update('content_checksum_data=' + obj.content_checksum_data)
+          else
+            digest.update('content=')
+            yield(obj.content)
+          end
 
-          digest.update(',attributes=')
-          yield(obj.attributes)
+          if obj.attributes_checksum_data
+            digest.update(',attributes_checksum_data=' + obj.attributes_checksum_data)
+          else
+            digest.update(',attributes=')
+            yield(obj.attributes)
+          end
 
           digest.update(',identifier=')
           yield(obj.identifier)
@@ -174,6 +221,15 @@ module Nanoc::Int
       end
     end
 
+    class ItemRepUpdateBehavior < UpdateBehavior
+      def self.update(obj, digest)
+        digest.update('item=')
+        yield(obj.item)
+        digest.update(',name=')
+        yield(obj.name)
+      end
+    end
+
     class PathnameUpdateBehavior < UpdateBehavior
       def self.update(obj, digest)
         filename = obj.to_s
diff --git a/lib/nanoc/base/services/compiler.rb b/lib/nanoc/base/services/compiler.rb
new file mode 100644
index 0000000..c5df878
--- /dev/null
+++ b/lib/nanoc/base/services/compiler.rb
@@ -0,0 +1,377 @@
+module Nanoc::Int
+  # Responsible for compiling a site’s item representations.
+  #
+  # The compilation process makes use of notifications (see
+  # {Nanoc::Int::NotificationCenter}) to track dependencies between items,
+  # layouts, etc. The following notifications are used:
+  #
+  # * `compilation_started` — indicates that the compiler has started
+  #   compiling this item representation. Has one argument: the item
+  #   representation itself. Only one item can be compiled at a given moment;
+  #   therefore, it is not possible to get two consecutive
+  #   `compilation_started` notifications without also getting a
+  #   `compilation_ended` notification in between them.
+  #
+  # * `compilation_ended` — indicates that the compiler has finished compiling
+  #   this item representation (either successfully or with failure). Has one
+  #   argument: the item representation itself.
+  #
+  # @api private
+  class Compiler
+    # Provides common functionality for accesing “context” of an item that is being compiled.
+    class CompilationContext
+      def initialize(action_provider:, reps:, site:, compiled_content_cache:)
+        @action_provider = action_provider
+        @reps = reps
+        @site = site
+        @compiled_content_cache = compiled_content_cache
+      end
+
+      def filter_name_and_args_for_layout(layout)
+        mem = @action_provider.memory_for(layout)
+        if mem.nil? || mem.size != 1 || !mem[0].is_a?(Nanoc::Int::ProcessingActions::Filter)
+          raise Nanoc::Int::Errors::UndefinedFilterForLayout.new(layout)
+        end
+        [mem[0].filter_name, mem[0].params]
+      end
+
+      def create_view_context(dependency_tracker)
+        Nanoc::ViewContext.new(
+          reps: @reps,
+          items: @site.items,
+          dependency_tracker: dependency_tracker,
+          compilation_context: self,
+        )
+      end
+
+      def assigns_for(rep, dependency_tracker)
+        content_or_filename_assigns =
+          if rep.binary?
+            { filename: rep.snapshot_contents[:last].filename }
+          else
+            { content: rep.snapshot_contents[:last].string }
+          end
+
+        view_context = create_view_context(dependency_tracker)
+
+        content_or_filename_assigns.merge(
+          item: Nanoc::ItemWithRepsView.new(rep.item, view_context),
+          rep: Nanoc::ItemRepView.new(rep, view_context),
+          item_rep: Nanoc::ItemRepView.new(rep, view_context),
+          items: Nanoc::ItemCollectionWithRepsView.new(@site.items, view_context),
+          layouts: Nanoc::LayoutCollectionView.new(@site.layouts, view_context),
+          config: Nanoc::ConfigView.new(@site.config, view_context),
+        )
+      end
+
+      def site
+        @site
+      end
+
+      def compiled_content_cache
+        @compiled_content_cache
+      end
+    end
+
+    # Provides functionality for (re)calculating the content of an item rep, without caching or
+    # outdatedness checking.
+    class RecalculatePhase
+      include Nanoc::Int::ContractsSupport
+
+      def initialize(action_provider:, dependency_store:, compilation_context:)
+        @action_provider = action_provider
+        @dependency_store = dependency_store
+        @compilation_context = compilation_context
+      end
+
+      contract Nanoc::Int::ItemRep, C::KeywordArgs[is_outdated: C::Bool] => C::Any
+      def run(rep, is_outdated:) # rubocop:disable Lint/UnusedMethodArgument
+        dependency_tracker = Nanoc::Int::DependencyTracker.new(@dependency_store)
+        dependency_tracker.enter(rep.item)
+
+        executor = Nanoc::Int::Executor.new(rep, @compilation_context, dependency_tracker)
+
+        @action_provider.memory_for(rep).each do |action|
+          case action
+          when Nanoc::Int::ProcessingActions::Filter
+            executor.filter(action.filter_name, action.params)
+          when Nanoc::Int::ProcessingActions::Layout
+            executor.layout(action.layout_identifier, action.params)
+          when Nanoc::Int::ProcessingActions::Snapshot
+            executor.snapshot(action.snapshot_name)
+          else
+            raise Nanoc::Int::Errors::InternalInconsistency, "unknown action #{action.inspect}"
+          end
+        end
+      ensure
+        dependency_tracker.exit
+      end
+    end
+
+    # Provides functionality for (re)calculating the content of an item rep, with caching or
+    # outdatedness checking. Delegates to RecalculatePhase if outdated or no cache available.
+    class CachePhase
+      include Nanoc::Int::ContractsSupport
+
+      def initialize(compiled_content_cache:, wrapped:)
+        @compiled_content_cache = compiled_content_cache
+        @wrapped = wrapped
+      end
+
+      contract Nanoc::Int::ItemRep, C::KeywordArgs[is_outdated: C::Bool] => C::Any
+      def run(rep, is_outdated:)
+        if can_reuse_content_for_rep?(rep, is_outdated: is_outdated)
+          Nanoc::Int::NotificationCenter.post(:cached_content_used, rep)
+          rep.snapshot_contents = @compiled_content_cache[rep]
+        else
+          @wrapped.run(rep, is_outdated: is_outdated)
+        end
+
+        rep.compiled = true
+        @compiled_content_cache[rep] = rep.snapshot_contents
+      end
+
+      contract Nanoc::Int::ItemRep, C::KeywordArgs[is_outdated: C::Bool] => C::Bool
+      def can_reuse_content_for_rep?(rep, is_outdated:)
+        !is_outdated && !@compiled_content_cache[rep].nil?
+      end
+    end
+
+    # Provides functionality for suspending and resuming item rep compilation (using fibers).
+    class ResumePhase
+      include Nanoc::Int::ContractsSupport
+
+      def initialize(wrapped:)
+        @wrapped = wrapped
+      end
+
+      contract Nanoc::Int::ItemRep, C::KeywordArgs[is_outdated: C::Bool] => C::Any
+      def run(rep, is_outdated:)
+        fiber = fiber_for(rep, is_outdated: is_outdated)
+        while fiber.alive?
+          Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+          res = fiber.resume
+
+          case res
+          when Nanoc::Int::Errors::UnmetDependency
+            Nanoc::Int::NotificationCenter.post(:compilation_suspended, rep, res)
+            raise(res)
+          when Proc
+            fiber.resume(res.call)
+          else
+            # TODO: raise
+          end
+        end
+
+        Nanoc::Int::NotificationCenter.post(:compilation_ended, rep)
+      end
+
+      private
+
+      contract Nanoc::Int::ItemRep, C::KeywordArgs[is_outdated: C::Bool] => Fiber
+      def fiber_for(rep, is_outdated:)
+        @fibers ||= {}
+
+        @fibers[rep] ||=
+          Fiber.new do
+            @wrapped.run(rep, is_outdated: is_outdated)
+            @fibers.delete(rep)
+          end
+
+        @fibers[rep]
+      end
+    end
+
+    class WritePhase
+      include Nanoc::Int::ContractsSupport
+
+      def initialize(wrapped:)
+        @wrapped = wrapped
+      end
+
+      contract Nanoc::Int::ItemRep, C::KeywordArgs[is_outdated: C::Bool] => C::Any
+      def run(rep, is_outdated:)
+        @wrapped.run(rep, is_outdated: is_outdated)
+
+        rep.snapshot_defs.each do |sdef|
+          ItemRepWriter.new.write(rep, sdef.name)
+        end
+      end
+    end
+
+    include Nanoc::Int::ContractsSupport
+
+    # @api private
+    attr_reader :site
+
+    # @api private
+    attr_reader :compiled_content_cache
+
+    # @api private
+    attr_reader :checksum_store
+
+    # @api private
+    attr_reader :rule_memory_store
+
+    # @api private
+    attr_reader :action_provider
+
+    # @api private
+    attr_reader :dependency_store
+
+    # @api private
+    attr_reader :outdatedness_checker
+
+    # @api private
+    attr_reader :reps
+
+    def initialize(site, compiled_content_cache:, checksum_store:, rule_memory_store:, action_provider:, dependency_store:, outdatedness_checker:, reps:)
+      @site = site
+
+      @compiled_content_cache = compiled_content_cache
+      @checksum_store         = checksum_store
+      @rule_memory_store      = rule_memory_store
+      @dependency_store       = dependency_store
+      @outdatedness_checker   = outdatedness_checker
+      @reps                   = reps
+      @action_provider        = action_provider
+    end
+
+    def run_all
+      @action_provider.preprocess(@site)
+      build_reps
+      prune
+      run
+      @action_provider.postprocess(@site, @reps)
+    end
+
+    def run
+      load_stores
+      @site.freeze
+
+      compile_reps
+      store
+    ensure
+      Nanoc::Int::TempFilenameFactory.instance.cleanup(
+        Nanoc::Filter::TMP_BINARY_ITEMS_DIR,
+      )
+      Nanoc::Int::TempFilenameFactory.instance.cleanup(
+        Nanoc::Int::ItemRepWriter::TMP_TEXT_ITEMS_DIR,
+      )
+    end
+
+    def load_stores
+      # FIXME: icky hack to update the dependency/checksum store’s list of objects
+      # (does not include preprocessed objects otherwise)
+      dependency_store.objects = site.items.to_a + site.layouts.to_a
+      checksum_store.objects = site.items.to_a + site.layouts.to_a + site.code_snippets + [site.config]
+
+      stores.each(&:load)
+    end
+
+    # Store the modified helper data used for compiling the site.
+    #
+    # @return [void]
+    def store
+      # Calculate rule memory
+      (@reps.to_a + @site.layouts.to_a).each do |obj|
+        rule_memory_store[obj] = action_provider.memory_for(obj).serialize
+      end
+
+      # Calculate checksums
+      objects_to_checksum =
+        site.items.to_a + site.layouts.to_a + site.code_snippets + [site.config]
+      objects_to_checksum.each { |obj| checksum_store.add(obj) }
+
+      # Store
+      stores.each(&:store)
+    end
+
+    def build_reps
+      builder = Nanoc::Int::ItemRepBuilder.new(
+        site, action_provider, @reps
+      )
+      builder.run
+    end
+
+    def compilation_context
+      @_compilation_context ||= CompilationContext.new(
+        action_provider: action_provider,
+        reps: @reps,
+        site: @site,
+        compiled_content_cache: compiled_content_cache,
+      )
+    end
+
+    private
+
+    def prune
+      if site.config[:prune][:auto_prune]
+        Nanoc::Pruner.new(site.config, reps, exclude: prune_config_exclude).run
+      end
+    end
+
+    def prune_config
+      site.config[:prune] || {}
+    end
+
+    def prune_config_exclude
+      prune_config[:exclude] || {}
+    end
+
+    def compile_reps
+      outdated_items = @reps.select { |r| outdatedness_checker.outdated?(r) }.map(&:item).uniq
+      outdated_items.each { |i| @dependency_store.forget_dependencies_for(i) }
+
+      reps_to_recompile = Set.new(outdated_items.flat_map { |i| @reps[i] })
+      selector = Nanoc::Int::ItemRepSelector.new(reps_to_recompile)
+      selector.each do |rep|
+        handle_errors_while(rep) { compile_rep(rep, is_outdated: reps_to_recompile.include?(rep)) }
+      end
+    end
+
+    def handle_errors_while(item_rep)
+      yield
+    rescue => e
+      raise Nanoc::Int::Errors::CompilationError.new(e, item_rep)
+    end
+
+    def compile_rep(rep, is_outdated:)
+      item_rep_compiler.run(rep, is_outdated: is_outdated)
+    end
+
+    def item_rep_compiler
+      @_item_rep_compiler ||= begin
+        recalculate_phase = RecalculatePhase.new(
+          action_provider: action_provider,
+          dependency_store: @dependency_store,
+          compilation_context: compilation_context,
+        )
+
+        cache_phase = CachePhase.new(
+          compiled_content_cache: compiled_content_cache,
+          wrapped: recalculate_phase,
+        )
+
+        resume_phase = ResumePhase.new(
+          wrapped: cache_phase,
+        )
+
+        WritePhase.new(
+          wrapped: resume_phase,
+        )
+      end
+    end
+
+    # Returns all stores that can load/store data that can be used for
+    # compilation.
+    def stores
+      [
+        checksum_store,
+        compiled_content_cache,
+        @dependency_store,
+        rule_memory_store,
+      ]
+    end
+  end
+end
diff --git a/lib/nanoc/base/services/compiler_loader.rb b/lib/nanoc/base/services/compiler_loader.rb
index 9fa809b..77fde3a 100644
--- a/lib/nanoc/base/services/compiler_loader.rb
+++ b/lib/nanoc/base/services/compiler_loader.rb
@@ -1,18 +1,20 @@
 module Nanoc::Int
   # @api private
   class CompilerLoader
-    def load(site)
-      rule_memory_store = Nanoc::Int::RuleMemoryStore.new
+    def load(site, action_provider: nil)
+      rule_memory_store = Nanoc::Int::RuleMemoryStore.new(env_name: site.config.env_name)
 
       dependency_store =
-        Nanoc::Int::DependencyStore.new(site.items.to_a + site.layouts.to_a)
+        Nanoc::Int::DependencyStore.new(site.items.to_a + site.layouts.to_a, env_name: site.config.env_name)
+
+      objects = site.items.to_a + site.layouts.to_a + site.code_snippets + [site.config]
 
       checksum_store =
-        Nanoc::Int::ChecksumStore.new(site: site)
+        Nanoc::Int::ChecksumStore.new(site: site, objects: objects)
 
       item_rep_repo = Nanoc::Int::ItemRepRepo.new
 
-      action_provider = Nanoc::Int::ActionProvider.named(:rule_dsl).for(site)
+      action_provider ||= Nanoc::Int::ActionProvider.named(:rule_dsl).for(site)
 
       outdatedness_checker =
         Nanoc::Int::OutdatednessChecker.new(
@@ -24,8 +26,14 @@ module Nanoc::Int
           reps: item_rep_repo,
         )
 
+      compiled_content_cache =
+        Nanoc::Int::CompiledContentCache.new(
+          env_name: site.config.env_name,
+          items: site.items,
+        )
+
       params = {
-        compiled_content_cache: Nanoc::Int::CompiledContentCache.new,
+        compiled_content_cache: compiled_content_cache,
         checksum_store: checksum_store,
         rule_memory_store: rule_memory_store,
         dependency_store: dependency_store,
diff --git a/lib/nanoc/base/services/dependency_tracker.rb b/lib/nanoc/base/services/dependency_tracker.rb
new file mode 100644
index 0000000..750c042
--- /dev/null
+++ b/lib/nanoc/base/services/dependency_tracker.rb
@@ -0,0 +1,55 @@
+module Nanoc::Int
+  # @api private
+  class DependencyTracker
+    include Nanoc::Int::ContractsSupport
+
+    C_OBJ = C::Or[Nanoc::Int::Item, Nanoc::Int::Layout]
+    C_ARGS = C::KeywordArgs[raw_content: C::Optional[C::Bool], attributes: C::Optional[C::Bool], compiled_content: C::Optional[C::Bool], path: C::Optional[C::Bool]]
+
+    class Null
+      include Nanoc::Int::ContractsSupport
+
+      contract C_OBJ, C_ARGS => C::Any
+      def enter(_obj, raw_content: false, attributes: false, compiled_content: false, path: false); end
+
+      contract C_OBJ => C::Any
+      def exit; end
+
+      contract C_OBJ, C_ARGS => C::Any
+      def bounce(_obj, raw_content: false, attributes: false, compiled_content: false, path: false); end
+    end
+
+    def initialize(dependency_store)
+      @dependency_store = dependency_store
+      @stack = []
+    end
+
+    contract C_OBJ, C_ARGS => C::Any
+    def enter(obj, raw_content: false, attributes: false, compiled_content: false, path: false)
+      unless @stack.empty?
+        Nanoc::Int::NotificationCenter.post(:dependency_created, @stack.last, obj)
+        @dependency_store.record_dependency(
+          @stack.last,
+          obj,
+          raw_content: raw_content,
+          attributes: attributes,
+          compiled_content: compiled_content,
+          path: path,
+        )
+      end
+
+      @stack.push(obj)
+    end
+
+    contract C_OBJ => C::Any
+    def exit
+      @stack.pop
+    end
+
+    contract C_OBJ, C_ARGS => C::Any
+    def bounce(obj, raw_content: false, attributes: false, compiled_content: false, path: false)
+      enter(obj, raw_content: raw_content, attributes: attributes, compiled_content: compiled_content, path: path)
+      exit
+    end
+  end
+end
diff --git a/lib/nanoc/base/services/executor.rb b/lib/nanoc/base/services/executor.rb
index 98740d1..bdb00ab 100644
--- a/lib/nanoc/base/services/executor.rb
+++ b/lib/nanoc/base/services/executor.rb
@@ -7,23 +7,24 @@ module Nanoc
         end
       end
 
-      def initialize(compiler, dependency_tracker)
-        @compiler = compiler
+      def initialize(rep, compilation_context, dependency_tracker)
+        @rep = rep
+        @compilation_context = compilation_context
         @dependency_tracker = dependency_tracker
       end
 
-      def filter(rep, filter_name, filter_args = {})
-        filter = filter_for_filtering(rep, filter_name)
+      def filter(filter_name, filter_args = {})
+        filter = filter_for_filtering(@rep, filter_name)
 
         begin
-          Nanoc::Int::NotificationCenter.post(:filtering_started, rep, filter_name)
+          Nanoc::Int::NotificationCenter.post(:filtering_started, @rep, filter_name)
 
           # Run filter
-          last = rep.snapshot_contents[:last]
-          source = rep.binary? ? last.filename : last.string
+          last = @rep.snapshot_contents[:last]
+          source = @rep.binary? ? last.filename : last.string
           filter_args.freeze
           result = filter.setup_and_run(source, filter_args)
-          rep.snapshot_contents[:last] =
+          @rep.snapshot_contents[:last] =
             if filter.class.to_binary?
               Nanoc::Int::BinaryContent.new(filter.output_filename).tap(&:freeze)
             else
@@ -34,17 +35,14 @@ module Nanoc
           if filter.class.to_binary? && !File.file?(filter.output_filename)
             raise OutputNotWrittenError.new(filter_name, filter.output_filename)
           end
-
-          # Create snapshot
-          snapshot(rep, rep.snapshot_contents[:post] ? :post : :pre, final: false) unless rep.binary?
         ensure
-          Nanoc::Int::NotificationCenter.post(:filtering_ended, rep, filter_name)
+          Nanoc::Int::NotificationCenter.post(:filtering_ended, @rep, filter_name)
         end
       end
 
-      def layout(rep, layout_identifier, extra_filter_args = nil)
+      def layout(layout_identifier, extra_filter_args = nil)
         layout = find_layout(layout_identifier)
-        filter_name, filter_args = *@compiler.filter_name_and_args_for_layout(layout)
+        filter_name, filter_args = *@compilation_context.filter_name_and_args_for_layout(layout)
         if filter_name.nil?
           raise Nanoc::Int::Errors::Generic, "Cannot find rule for layout matching #{layout_identifier}"
         end
@@ -52,68 +50,41 @@ module Nanoc
         filter_args.freeze
 
         # Check whether item can be laid out
-        raise Nanoc::Int::Errors::CannotLayoutBinaryItem.new(rep) if rep.binary?
-
-        # Create "pre" snapshot
-        if rep.snapshot_contents[:post].nil?
-          snapshot(rep, :pre, final: true)
-        end
+        raise Nanoc::Int::Errors::CannotLayoutBinaryItem.new(@rep) if @rep.binary?
 
         # Create filter
         klass = Nanoc::Filter.named(filter_name)
         raise Nanoc::Int::Errors::UnknownFilter.new(filter_name) if klass.nil?
-        view_context = @compiler.create_view_context(@dependency_tracker)
+        view_context = @compilation_context.create_view_context(@dependency_tracker)
         layout_view = Nanoc::LayoutView.new(layout, view_context)
-        filter = klass.new(assigns_for(rep).merge({ layout: layout_view }))
+        filter = klass.new(assigns_for(@rep).merge(layout: layout_view))
 
         # Visit
-        @dependency_tracker.bounce(layout)
+        @dependency_tracker.bounce(layout, raw_content: true)
 
         begin
-          # Notify start
-          Nanoc::Int::NotificationCenter.post(:processing_started, layout)
-          Nanoc::Int::NotificationCenter.post(:filtering_started,  rep, filter_name)
+          Nanoc::Int::NotificationCenter.post(:filtering_started, @rep, filter_name)
 
           # Layout
           content = layout.content
           arg = content.binary? ? content.filename : content.string
           res = filter.setup_and_run(arg, filter_args)
-          rep.snapshot_contents[:last] = Nanoc::Int::TextualContent.new(res).tap(&:freeze)
-
-          # Create "post" snapshot
-          snapshot(rep, :post, final: false)
+          @rep.snapshot_contents[:last] = Nanoc::Int::TextualContent.new(res).tap(&:freeze)
         ensure
-          # Notify end
-          Nanoc::Int::NotificationCenter.post(:filtering_ended,  rep, filter_name)
-          Nanoc::Int::NotificationCenter.post(:processing_ended, layout)
+          Nanoc::Int::NotificationCenter.post(:filtering_ended, @rep, filter_name)
         end
       end
 
-      def snapshot(rep, snapshot_name, final: true, path: nil) # rubocop:disable Lint/UnusedMethodArgument
-        # NOTE: :path is irrelevant
-
-        unless rep.binary?
-          rep.snapshot_contents[snapshot_name] = rep.snapshot_contents[:last]
-        end
-
-        if snapshot_name == :pre && final
-          rep.snapshot_defs << Nanoc::Int::SnapshotDef.new(:pre, true)
-        end
-
-        if final
-          raw_path = rep.raw_path(snapshot: snapshot_name)
-          if raw_path
-            ItemRepWriter.new.write(rep, raw_path)
-          end
-        end
+      def snapshot(snapshot_name)
+        @rep.snapshot_contents[snapshot_name] = @rep.snapshot_contents[:last]
       end
 
       def assigns_for(rep)
-        @compiler.assigns_for(rep, @dependency_tracker)
+        @compilation_context.assigns_for(rep, @dependency_tracker)
       end
 
       def layouts
-        @compiler.site.layouts
+        @compilation_context.site.layouts
       end
 
       def find_layout(arg)
@@ -144,7 +115,7 @@ module Nanoc
       end
 
       def use_globs?
-        @compiler.site.config[:string_pattern_type] == 'glob'
+        @compilation_context.site.config[:string_pattern_type] == 'glob'
       end
     end
   end
diff --git a/lib/nanoc/base/compilation/filter.rb b/lib/nanoc/base/services/filter.rb
similarity index 96%
rename from lib/nanoc/base/compilation/filter.rb
rename to lib/nanoc/base/services/filter.rb
index 11d5aac..633b011 100644
--- a/lib/nanoc/base/compilation/filter.rb
+++ b/lib/nanoc/base/services/filter.rb
@@ -129,7 +129,7 @@ module Nanoc
     # Sets up the filter and runs the filter. This method passes its arguments
     # to {#run} unchanged and returns the return value from {#run}.
     #
-    # @see {#run}
+    # @see #run
     #
     # @api private
     def setup_and_run(*args)
@@ -184,6 +184,11 @@ module Nanoc
       end
     end
 
+    # @api private
+    def on_main_fiber(&block)
+      Fiber.yield(block)
+    end
+
     # Creates a dependency from the item that is currently being filtered onto
     # the given collection of items. In other words, require the given items
     # to be compiled first before this items is processed.
@@ -195,12 +200,12 @@ module Nanoc
 
       # Notify
       dependency_tracker = @assigns[:item]._context.dependency_tracker
-      items.each { |item| dependency_tracker.bounce(item) }
+      items.each { |item| dependency_tracker.bounce(item, compiled_content: true) }
 
       # Raise unmet dependency error if necessary
       items.each do |item|
         rep = orig_items.sample._context.reps[item].find { |r| !r.compiled? }
-        raise Nanoc::Int::Errors::UnmetDependency.new(rep) if rep
+        Fiber.yield(Nanoc::Int::Errors::UnmetDependency.new(rep)) if rep
       end
     end
   end
diff --git a/lib/nanoc/base/services/item_rep_builder.rb b/lib/nanoc/base/services/item_rep_builder.rb
index 6a2816d..2b8ecdd 100644
--- a/lib/nanoc/base/services/item_rep_builder.rb
+++ b/lib/nanoc/base/services/item_rep_builder.rb
@@ -17,6 +17,10 @@ module Nanoc::Int
       end
 
       Nanoc::Int::ItemRepRouter.new(@reps, @action_provider, @site).run
+
+      @reps.each do |rep|
+        rep.snapshot_defs = @action_provider.snapshots_defs_for(rep)
+      end
     end
   end
 end
diff --git a/lib/nanoc/base/services/item_rep_router.rb b/lib/nanoc/base/services/item_rep_router.rb
index 853850f..e5413c2 100644
--- a/lib/nanoc/base/services/item_rep_router.rb
+++ b/lib/nanoc/base/services/item_rep_router.rb
@@ -9,6 +9,12 @@ module Nanoc::Int
       end
     end
 
+    class RouteWithoutSlashError < ::Nanoc::Error
+      def initialize(output_path, rep)
+        super("The item representation #{rep.inspect} is routed to #{output_path}, which does not start with a slash, as required.")
+      end
+    end
+
     def initialize(reps, action_provider, site)
       @reps = reps
       @action_provider = action_provider
@@ -18,18 +24,21 @@ module Nanoc::Int
     def run
       paths_to_reps = {}
       @reps.each do |rep|
-        mem = @action_provider.memory_for(rep)
-        mem.snapshot_actions.each do |snapshot_action|
-          route_rep(rep, snapshot_action, paths_to_reps)
+        @action_provider.paths_for(rep).each do |snapshot_name, path|
+          route_rep(rep, path, snapshot_name, paths_to_reps)
         end
       end
     end
 
-    def route_rep(rep, snapshot_action, paths_to_reps)
-      basic_path = snapshot_action.path
+    def route_rep(rep, path, snapshot_name, paths_to_reps)
+      basic_path = path
       return if basic_path.nil?
       basic_path = basic_path.encode('UTF-8')
 
+      unless basic_path.start_with?('/')
+        raise RouteWithoutSlashError.new(basic_path, rep)
+      end
+
       # Check for duplicate paths
       if paths_to_reps.key?(basic_path)
         raise IdenticalRoutesError.new(basic_path, paths_to_reps[basic_path], rep)
@@ -37,8 +46,8 @@ module Nanoc::Int
         paths_to_reps[basic_path] = rep
       end
 
-      rep.raw_paths[snapshot_action.snapshot_name] = @site.config[:output_dir] + basic_path
-      rep.paths[snapshot_action.snapshot_name] = strip_index_filename(basic_path)
+      rep.raw_paths[snapshot_name] = @site.config[:output_dir] + basic_path
+      rep.paths[snapshot_name] = strip_index_filename(basic_path)
     end
 
     def strip_index_filename(basic_path)
diff --git a/lib/nanoc/base/services/item_rep_selector.rb b/lib/nanoc/base/services/item_rep_selector.rb
index ee6f56c..2778f76 100644
--- a/lib/nanoc/base/services/item_rep_selector.rb
+++ b/lib/nanoc/base/services/item_rep_selector.rb
@@ -17,8 +17,8 @@ module Nanoc::Int
         begin
           yield(rep)
           graph.delete_vertex(rep)
-        rescue Nanoc::Int::Errors::UnmetDependency => e
-          handle_dependency_error(e, rep, graph)
+        rescue => e
+          handle_error(e, rep, graph)
         end
       end
 
@@ -28,6 +28,21 @@ module Nanoc::Int
       end
     end
 
+    def handle_error(e, rep, graph)
+      actual_error =
+        if e.is_a?(Nanoc::Int::Errors::CompilationError)
+          e.unwrap
+        else
+          e
+        end
+
+      if actual_error.is_a?(Nanoc::Int::Errors::UnmetDependency)
+        handle_dependency_error(actual_error, rep, graph)
+      else
+        raise(e)
+      end
+    end
+
     def handle_dependency_error(e, rep, graph)
       other_rep = e.rep
       graph.add_edge(other_rep, rep)
diff --git a/lib/nanoc/base/services/item_rep_writer.rb b/lib/nanoc/base/services/item_rep_writer.rb
index f6b6de6..fe60655 100644
--- a/lib/nanoc/base/services/item_rep_writer.rb
+++ b/lib/nanoc/base/services/item_rep_writer.rb
@@ -3,7 +3,10 @@ module Nanoc::Int
   class ItemRepWriter
     TMP_TEXT_ITEMS_DIR = 'text_items'.freeze
 
-    def write(item_rep, raw_path)
+    def write(item_rep, snapshot_name)
+      raw_path = item_rep.raw_path(snapshot: snapshot_name)
+      return unless raw_path
+
       # Create parent directory
       FileUtils.mkdir_p(File.dirname(raw_path))
 
@@ -15,7 +18,7 @@ module Nanoc::Int
         :will_write_rep, item_rep, raw_path
       )
 
-      content = item_rep.snapshot_contents[:last]
+      content = item_rep.snapshot_contents[snapshot_name]
       if content.binary?
         temp_path = content.filename
       else
diff --git a/lib/nanoc/base/services/outdatedness_checker.rb b/lib/nanoc/base/services/outdatedness_checker.rb
new file mode 100644
index 0000000..eb8ad19
--- /dev/null
+++ b/lib/nanoc/base/services/outdatedness_checker.rb
@@ -0,0 +1,181 @@
+module Nanoc::Int
+  # Responsible for determining whether an item or a layout is outdated.
+  #
+  # @api private
+  class OutdatednessChecker
+    class Basic
+      extend Nanoc::Int::Memoization
+
+      include Nanoc::Int::ContractsSupport
+
+      Rules = Nanoc::Int::OutdatednessRules
+
+      RULES_FOR_ITEM_REP =
+        [
+          Rules::RulesModified,
+          Rules::PathsModified,
+          Rules::ContentModified,
+          Rules::AttributesModified,
+          Rules::NotWritten,
+          Rules::CodeSnippetsModified,
+          Rules::ConfigurationModified,
+        ].freeze
+
+      RULES_FOR_LAYOUT =
+        [
+          Rules::RulesModified,
+          Rules::ContentModified,
+          Rules::AttributesModified,
+        ].freeze
+
+      contract C::KeywordArgs[outdatedness_checker: OutdatednessChecker, reps: Nanoc::Int::ItemRepRepo] => C::Any
+      def initialize(outdatedness_checker:, reps:)
+        @outdatedness_checker = outdatedness_checker
+        @reps = reps
+      end
+
+      contract C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] => C::Maybe[OutdatednessStatus]
+      def outdatedness_status_for(obj)
+        case obj
+        when Nanoc::Int::ItemRep
+          apply_rules(RULES_FOR_ITEM_REP, obj)
+        when Nanoc::Int::Item
+          apply_rules_multi(RULES_FOR_ITEM_REP, @reps[obj])
+        when Nanoc::Int::Layout
+          apply_rules(RULES_FOR_LAYOUT, obj)
+        else
+          raise Nanoc::Int::Errors::InternalInconsistency, "do not know how to check outdatedness of #{obj.inspect}"
+        end
+      end
+      memoize :outdatedness_status_for
+
+      private
+
+      contract C::ArrayOf[Class], C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout], OutdatednessStatus => C::Maybe[OutdatednessStatus]
+      def apply_rules(rules, obj, status = OutdatednessStatus.new)
+        rules.inject(status) do |acc, rule|
+          if !acc.useful_to_apply?(rule)
+            acc
+          elsif rule.instance.apply(obj, @outdatedness_checker)
+            acc.update(rule.instance.reason)
+          else
+            acc
+          end
+        end
+      end
+
+      contract C::ArrayOf[Class], C::ArrayOf[C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout]] => C::Maybe[OutdatednessStatus]
+      def apply_rules_multi(rules, objs)
+        objs.inject(OutdatednessStatus.new) { |acc, elem| apply_rules(rules, elem, acc) }
+      end
+    end
+
+    extend Nanoc::Int::Memoization
+
+    include Nanoc::Int::ContractsSupport
+
+    attr_reader :checksum_store
+    attr_reader :dependency_store
+    attr_reader :rule_memory_store
+    attr_reader :action_provider
+    attr_reader :site
+
+    Reasons = Nanoc::Int::OutdatednessReasons
+
+    # @param [Nanoc::Int::Site] site
+    # @param [Nanoc::Int::ChecksumStore] checksum_store
+    # @param [Nanoc::Int::DependencyStore] dependency_store
+    # @param [Nanoc::Int::RuleMemoryStore] rule_memory_store
+    # @param [Nanoc::Int::ActionProvider] action_provider
+    # @param [Nanoc::Int::ItemRepRepo] reps
+    def initialize(site:, checksum_store:, dependency_store:, rule_memory_store:, action_provider:, reps:)
+      @site = site
+      @checksum_store = checksum_store
+      @dependency_store = dependency_store
+      @rule_memory_store = rule_memory_store
+      @action_provider = action_provider
+      @reps = reps
+
+      @objects_outdated_due_to_dependencies = {}
+    end
+
+    contract C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] => C::Bool
+    # Checks whether the given object is outdated and therefore needs to be
+    # recompiled.
+    #
+    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
+    #   whose outdatedness should be checked.
+    #
+    # @return [Boolean] true if the object is outdated, false otherwise
+    def outdated?(obj)
+      !outdatedness_reason_for(obj).nil?
+    end
+
+    contract C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] => C::Maybe[Reasons::Generic]
+    # Calculates the reason why the given object is outdated.
+    #
+    # @param [Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] obj The object
+    #   whose outdatedness reason should be calculated.
+    #
+    # @return [Reasons::Generic, nil] The reason why the
+    #   given object is outdated, or nil if the object is not outdated.
+    def outdatedness_reason_for(obj)
+      reason = basic_outdatedness_reason_for(obj)
+      if reason.nil? && outdated_due_to_dependencies?(obj)
+        reason = Reasons::DependenciesOutdated
+      end
+      reason
+    end
+    memoize :outdatedness_reason_for
+
+    private
+
+    contract C::None => Basic
+    def basic
+      @_basic ||= Basic.new(outdatedness_checker: self, reps: @reps)
+    end
+
+    contract C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout] => C::Maybe[Reasons::Generic]
+    def basic_outdatedness_reason_for(obj)
+      # FIXME: Stop using this; it is no longer accurate, as there can be >1 reasons
+      basic.outdatedness_status_for(obj).reasons.first
+    end
+
+    contract C::Or[Nanoc::Int::Item, Nanoc::Int::ItemRep, Nanoc::Int::Layout], Hamster::Set => C::Bool
+    def outdated_due_to_dependencies?(obj, processed = Hamster::Set.new)
+      # Convert from rep to item if necessary
+      obj = obj.item if obj.is_a?(Nanoc::Int::ItemRep)
+
+      # Get from cache
+      if @objects_outdated_due_to_dependencies.key?(obj)
+        return @objects_outdated_due_to_dependencies[obj]
+      end
+
+      # Check processed
+      # Don’t return true; the false will be or’ed into a true if there
+      # really is a dependency that is causing outdatedness.
+      return false if processed.include?(obj)
+
+      # Calculate
+      is_outdated = dependency_store.dependencies_causing_outdatedness_of(obj).any? do |dep|
+        dependency_causes_outdatedness?(dep) ||
+          (dep.props.compiled_content? &&
+            outdated_due_to_dependencies?(dep.from, processed.merge([obj])))
+      end
+
+      # Cache
+      @objects_outdated_due_to_dependencies[obj] = is_outdated
+
+      # Done
+      is_outdated
+    end
+
+    contract Nanoc::Int::Dependency => C::Bool
+    def dependency_causes_outdatedness?(dependency)
+      return true if dependency.from.nil?
+
+      status = basic.outdatedness_status_for(dependency.from)
+      (status.props.active & dependency.props.active).any?
+    end
+  end
+end
diff --git a/lib/nanoc/base/services/outdatedness_rule.rb b/lib/nanoc/base/services/outdatedness_rule.rb
new file mode 100644
index 0000000..333f734
--- /dev/null
+++ b/lib/nanoc/base/services/outdatedness_rule.rb
@@ -0,0 +1,21 @@
+module Nanoc::Int
+  # @api private
+  class OutdatednessRule
+    include Nanoc::Int::ContractsSupport
+    include Singleton
+
+    def apply(_obj, _outdatedness_checker)
+      raise NotImplementedError.new('Nanoc::Int::OutdatednessRule subclasses must implement ##reason, and #apply')
+    end
+
+    contract C::None => String
+    def inspect
+      "#{self.class.name}(#{reason})"
+    end
+
+    # TODO: remove
+    def reason
+      raise NotImplementedError.new('Nanoc::Int::OutdatednessRule subclasses must implement ##reason, and #apply')
+    end
+  end
+end
diff --git a/lib/nanoc/base/services/outdatedness_rules.rb b/lib/nanoc/base/services/outdatedness_rules.rb
new file mode 100644
index 0000000..c59bc7c
--- /dev/null
+++ b/lib/nanoc/base/services/outdatedness_rules.rb
@@ -0,0 +1,121 @@
+module Nanoc::Int
+  # @api private
+  module OutdatednessRules
+    class CodeSnippetsModified < OutdatednessRule
+      extend Nanoc::Int::Memoization
+
+      include Nanoc::Int::ContractsSupport
+
+      def reason
+        Nanoc::Int::OutdatednessReasons::CodeSnippetsModified
+      end
+
+      def apply(_obj, outdatedness_checker)
+        any_snippets_modified?(outdatedness_checker)
+      end
+
+      private
+
+      def any_snippets_modified?(outdatedness_checker)
+        outdatedness_checker.site.code_snippets.any? do |cs|
+          ch_old = outdatedness_checker.checksum_store[cs]
+          ch_new = Nanoc::Int::Checksummer.calc(cs)
+          ch_old != ch_new
+        end
+      end
+      memoize :any_snippets_modified?
+    end
+
+    class ConfigurationModified < OutdatednessRule
+      extend Nanoc::Int::Memoization
+
+      def reason
+        Nanoc::Int::OutdatednessReasons::ConfigurationModified
+      end
+
+      def apply(_obj, outdatedness_checker)
+        config_modified?(outdatedness_checker)
+      end
+
+      private
+
+      def config_modified?(outdatedness_checker)
+        obj = outdatedness_checker.site.config
+        ch_old = outdatedness_checker.checksum_store[obj]
+        ch_new = Nanoc::Int::Checksummer.calc(obj)
+        ch_old != ch_new
+      end
+      memoize :config_modified?
+    end
+
+    class NotWritten < OutdatednessRule
+      def reason
+        Nanoc::Int::OutdatednessReasons::NotWritten
+      end
+
+      def apply(obj, _outdatedness_checker)
+        # FIXME: check all paths (for all snapshots)
+        obj.raw_path && !File.file?(obj.raw_path)
+      end
+    end
+
+    class ContentModified < OutdatednessRule
+      def reason
+        Nanoc::Int::OutdatednessReasons::ContentModified
+      end
+
+      def apply(obj, outdatedness_checker)
+        obj = obj.item if obj.is_a?(Nanoc::Int::ItemRep)
+
+        ch_old = outdatedness_checker.checksum_store.content_checksum_for(obj)
+        ch_new = Nanoc::Int::Checksummer.calc_for_content_of(obj)
+        ch_old != ch_new
+      end
+    end
+
+    class AttributesModified < OutdatednessRule
+      def reason
+        Nanoc::Int::OutdatednessReasons::AttributesModified
+      end
+
+      def apply(obj, outdatedness_checker)
+        obj = obj.item if obj.is_a?(Nanoc::Int::ItemRep)
+
+        ch_old = outdatedness_checker.checksum_store.attributes_checksum_for(obj)
+        ch_new = Nanoc::Int::Checksummer.calc_for_attributes_of(obj)
+        ch_old != ch_new
+      end
+    end
+
+    class RulesModified < OutdatednessRule
+      def reason
+        Nanoc::Int::OutdatednessReasons::RulesModified
+      end
+
+      def apply(obj, outdatedness_checker)
+        mem_old = outdatedness_checker.rule_memory_store[obj]
+        mem_new = outdatedness_checker.action_provider.memory_for(obj).serialize
+        !mem_old.eql?(mem_new)
+      end
+    end
+
+    class PathsModified < OutdatednessRule
+      def reason
+        Nanoc::Int::OutdatednessReasons::PathsModified
+      end
+
+      def apply(obj, outdatedness_checker)
+        # FIXME: Prefer to not work on serialised version
+
+        mem_old = outdatedness_checker.rule_memory_store[obj]
+        mem_new = outdatedness_checker.action_provider.memory_for(obj).serialize
+        return true if mem_old.nil?
+
+        paths_old = mem_old.select { |pa| pa[0] == :snapshot }
+        paths_new = mem_new.select { |pa| pa[0] == :snapshot }
+
+        paths_old != paths_new
+      end
+    end
+  end
+end
diff --git a/lib/nanoc/base/services/pruner.rb b/lib/nanoc/base/services/pruner.rb
new file mode 100644
index 0000000..65e9863
--- /dev/null
+++ b/lib/nanoc/base/services/pruner.rb
@@ -0,0 +1,110 @@
+require 'find'
+
+module Nanoc
+  # Responsible for finding and deleting files in the site’s output directory
+  # that are not managed by Nanoc.
+  #
+  # @api private
+  class Pruner
+    # @param [Nanoc::Int::Configuration] config
+    #
+    # @param [Nanoc::Int::ItemRepRepo] reps
+    #
+    # @param [Boolean] dry_run true if the files to be deleted
+    #   should only be printed instead of actually deleted, false if the files
+    #   should actually be deleted.
+    #
+    # @param [Enumerable<String>] exclude
+    def initialize(config, reps, dry_run: false, exclude: [])
+      @config  = config
+      @reps    = reps
+      @dry_run = dry_run
+      @exclude = Set.new(exclude)
+    end
+
+    # Prunes all output files not managed by Nanoc.
+    #
+    # @return [void]
+    def run
+      return unless File.directory?(@config[:output_dir])
+
+      compiled_files = @reps.flat_map { |r| r.raw_paths.values }.compact
+      present_files, present_dirs = files_and_dirs_in(@config[:output_dir] + '/')
+
+      remove_stray_files(present_files, compiled_files)
+      remove_empty_directories(present_dirs)
+    end
+
+    def exclude?(component)
+      @exclude.include?(component)
+    end
+
+    # @param [String] filename The filename to check
+    #
+    # @return [Boolean] true if the given file is excluded, false otherwise
+    def filename_excluded?(filename)
+      pathname = Pathname.new(filename)
+      @exclude.any? { |e| pathname.__nanoc_include_component?(e) }
+    end
+
+    # @api private
+    def remove_stray_files(present_files, compiled_files)
+      (present_files - compiled_files).each do |f|
+        delete_file(f) unless exclude?(f)
+      end
+    end
+
+    # @api private
+    def remove_empty_directories(present_dirs)
+      present_dirs.reverse_each do |dir|
+        next if Dir.foreach(dir) { |n| break true if n !~ /\A\.\.?\z/ }
+        next if exclude?(dir)
+        delete_dir(dir)
+      end
+    end
+
+    # @api private
+    def files_and_dirs_in(dir)
+      present_files = []
+      present_dirs = []
+
+      Find.find(dir) do |f|
+        basename = File.basename(f)
+
+        case File.ftype(f)
+        when 'file'.freeze
+          unless exclude?(basename)
+            present_files << f
+          end
+        when 'directory'.freeze
+          if exclude?(basename)
+            Find.prune
+          else
+            present_dirs << f
+          end
+        end
+      end
+
+      [present_files, present_dirs]
+    end
+
+    protected
+
+    def delete_file(file)
+      log_delete_and_run(file) { FileUtils.rm(file) }
+    end
+
+    def delete_dir(dir)
+      log_delete_and_run(dir) { Dir.rmdir(dir) }
+    end
+
+    def log_delete_and_run(thing)
+      if @dry_run
+        puts thing
+      else
+        Nanoc::CLI::Logger.instance.file(:high, :delete, thing)
+        yield
+      end
+    end
+  end
+end
diff --git a/lib/nanoc/base/views.rb b/lib/nanoc/base/views.rb
index 473e0b2..b0a38c6 100644
--- a/lib/nanoc/base/views.rb
+++ b/lib/nanoc/base/views.rb
@@ -24,3 +24,5 @@ require_relative 'views/mutable_layout_collection_view'
 
 require_relative 'views/post_compile_item_view'
 require_relative 'views/post_compile_item_collection_view'
+require_relative 'views/post_compile_item_rep_view'
+require_relative 'views/post_compile_item_rep_collection_view'
diff --git a/lib/nanoc/base/views/item_rep_collection_view.rb b/lib/nanoc/base/views/item_rep_collection_view.rb
index e40dc38..2018678 100644
--- a/lib/nanoc/base/views/item_rep_collection_view.rb
+++ b/lib/nanoc/base/views/item_rep_collection_view.rb
@@ -19,19 +19,24 @@ module Nanoc
       @item_reps
     end
 
+    # @api private
+    def view_class
+      Nanoc::ItemRepView
+    end
+
     def to_ary
-      @item_reps.map { |ir| Nanoc::ItemRepView.new(ir, @context) }
+      @item_reps.map { |ir| view_class.new(ir, @context) }
     end
 
     # Calls the given block once for each item rep, passing that item rep as a parameter.
     #
-    # @yieldparam [Nanoc::ItemRepView] item rep
+    # @yieldparam [Object] item rep view
     #
     # @yieldreturn [void]
     #
     # @return [self]
     def each
-      @item_reps.each { |ir| yield Nanoc::ItemRepView.new(ir, @context) }
+      @item_reps.each { |ir| yield view_class.new(ir, @context) }
       self
     end
 
@@ -51,7 +56,7 @@ module Nanoc
       case rep_name
       when Symbol
         res = @item_reps.find { |ir| ir.name == rep_name }
-        res && Nanoc::ItemRepView.new(res, @context)
+        res && view_class.new(res, @context)
       when Fixnum
         raise ArgumentError, "expected ItemRepCollectionView#[] to be called with a symbol (you likely want `.reps[:default]` rather than `.reps[#{rep_name}]`)"
       else
@@ -70,7 +75,7 @@ module Nanoc
     def fetch(rep_name)
       res = @item_reps.find { |ir| ir.name == rep_name }
       if res
-        Nanoc::ItemRepView.new(res, @context)
+        view_class.new(res, @context)
       else
         raise NoSuchItemRepError.new(rep_name)
       end
diff --git a/lib/nanoc/base/views/item_rep_view.rb b/lib/nanoc/base/views/item_rep_view.rb
index 5befd8c..be22745 100644
--- a/lib/nanoc/base/views/item_rep_view.rb
+++ b/lib/nanoc/base/views/item_rep_view.rb
@@ -42,7 +42,7 @@ module Nanoc
     #
     # @return [String] The content at the given snapshot.
     def compiled_content(snapshot: nil)
-      @context.dependency_tracker.bounce(unwrap.item)
+      @context.dependency_tracker.bounce(unwrap.item, compiled_content: true)
       @item_rep.compiled_content(snapshot: snapshot)
     end
 
@@ -56,7 +56,7 @@ module Nanoc
     #
     # @return [String] The item rep’s path.
     def path(snapshot: :last)
-      @context.dependency_tracker.bounce(unwrap.item)
+      @context.dependency_tracker.bounce(unwrap.item, path: true)
       @item_rep.path(snapshot: snapshot)
     end
 
@@ -69,7 +69,7 @@ module Nanoc
 
     # @api private
     def raw_path(snapshot: :last)
-      @context.dependency_tracker.bounce(unwrap.item)
+      @context.dependency_tracker.bounce(unwrap.item, path: true)
       @item_rep.raw_path(snapshot: snapshot)
     end
 
diff --git a/lib/nanoc/base/views/mixins/document_view_mixin.rb b/lib/nanoc/base/views/mixins/document_view_mixin.rb
index 5e88bba..fb16136 100644
--- a/lib/nanoc/base/views/mixins/document_view_mixin.rb
+++ b/lib/nanoc/base/views/mixins/document_view_mixin.rb
@@ -36,19 +36,19 @@ module Nanoc
 
     # @see Hash#[]
     def [](key)
-      @context.dependency_tracker.bounce(unwrap)
+      @context.dependency_tracker.bounce(unwrap, attributes: true)
       unwrap.attributes[key]
     end
 
     # @return [Hash]
     def attributes
-      @context.dependency_tracker.bounce(unwrap)
+      @context.dependency_tracker.bounce(unwrap, attributes: true)
       unwrap.attributes
     end
 
     # @see Hash#fetch
     def fetch(key, fallback = NONE, &_block)
-      @context.dependency_tracker.bounce(unwrap)
+      @context.dependency_tracker.bounce(unwrap, attributes: true)
 
       if unwrap.attributes.key?(key)
         unwrap.attributes[key]
@@ -63,7 +63,7 @@ module Nanoc
 
     # @see Hash#key?
     def key?(key)
-      @context.dependency_tracker.bounce(unwrap)
+      @context.dependency_tracker.bounce(unwrap, attributes: true)
       unwrap.attributes.key?(key)
     end
 
@@ -74,6 +74,7 @@ module Nanoc
 
     # @api private
     def raw_content
+      @context.dependency_tracker.bounce(unwrap, raw_content: true)
       unwrap.content.string
     end
 
diff --git a/lib/nanoc/base/views/mixins/with_reps_view_mixin.rb b/lib/nanoc/base/views/mixins/with_reps_view_mixin.rb
index 68bcebd..71cc65d 100644
--- a/lib/nanoc/base/views/mixins/with_reps_view_mixin.rb
+++ b/lib/nanoc/base/views/mixins/with_reps_view_mixin.rb
@@ -12,7 +12,7 @@ module Nanoc
     #   any).
     #
     # @return [String] The content of the given rep at the given snapshot.
-    def compiled_content(rep: :default, snapshot: :pre)
+    def compiled_content(rep: :default, snapshot: nil)
       reps.fetch(rep).compiled_content(snapshot: snapshot)
     end
 
diff --git a/lib/nanoc/base/views/post_compile_item_rep_collection_view.rb b/lib/nanoc/base/views/post_compile_item_rep_collection_view.rb
new file mode 100644
index 0000000..75e4a2c
--- /dev/null
+++ b/lib/nanoc/base/views/post_compile_item_rep_collection_view.rb
@@ -0,0 +1,8 @@
+module Nanoc
+  class PostCompileItemRepCollectionView < Nanoc::ItemRepCollectionView
+    # @api private
+    def view_class
+      Nanoc::PostCompileItemRepView
+    end
+  end
+end
diff --git a/lib/nanoc/base/views/post_compile_item_rep_view.rb b/lib/nanoc/base/views/post_compile_item_rep_view.rb
new file mode 100644
index 0000000..4bc0f70
--- /dev/null
+++ b/lib/nanoc/base/views/post_compile_item_rep_view.rb
@@ -0,0 +1,18 @@
+module Nanoc
+  class PostCompileItemRepView < ::Nanoc::ItemRepView
+    def compiled_content(snapshot: nil)
+      if unwrap.binary?
+        raise Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem.new(unwrap)
+      end
+
+      snapshot_contents = @context.compilation_context.compiled_content_cache[unwrap]
+      snapshot_name = snapshot || (snapshot_contents[:pre] ? :pre : :last)
+
+      if snapshot_contents[snapshot_name]
+        snapshot_contents[snapshot_name].string
+      else
+        raise Nanoc::Int::Errors::NoSuchSnapshot.new(unwrap, snapshot_name)
+      end
+    end
+  end
+end
diff --git a/lib/nanoc/base/views/post_compile_item_view.rb b/lib/nanoc/base/views/post_compile_item_view.rb
index 88ffb48..890ba46 100644
--- a/lib/nanoc/base/views/post_compile_item_view.rb
+++ b/lib/nanoc/base/views/post_compile_item_view.rb
@@ -1,5 +1,9 @@
 module Nanoc
   class PostCompileItemView < Nanoc::ItemWithRepsView
+    def reps
+      Nanoc::PostCompileItemRepCollectionView.new(@context.reps[unwrap], @context)
+    end
+
     # @deprecated Use {#modified_reps} instead
     def modified
       modified_reps
diff --git a/lib/nanoc/base/views/view_context.rb b/lib/nanoc/base/views/view_context.rb
index bd54821..f8bc794 100644
--- a/lib/nanoc/base/views/view_context.rb
+++ b/lib/nanoc/base/views/view_context.rb
@@ -4,13 +4,13 @@ module Nanoc
     attr_reader :reps
     attr_reader :items
     attr_reader :dependency_tracker
-    attr_reader :compiler
+    attr_reader :compilation_context
 
-    def initialize(reps:, items:, dependency_tracker:, compiler:)
+    def initialize(reps:, items:, dependency_tracker:, compilation_context:)
       @reps = reps
       @items = items
       @dependency_tracker = dependency_tracker
-      @compiler = compiler
+      @compilation_context = compilation_context
     end
   end
 end
diff --git a/lib/nanoc/checking.rb b/lib/nanoc/checking.rb
new file mode 100644
index 0000000..8b31d18
--- /dev/null
+++ b/lib/nanoc/checking.rb
@@ -0,0 +1,11 @@
+module Nanoc
+  # @api private
+  module Checking
+    autoload 'Check',  'nanoc/checking/check'
+    autoload 'DSL',    'nanoc/checking/dsl'
+    autoload 'Runner', 'nanoc/checking/runner.rb'
+    autoload 'Issue',  'nanoc/checking/issue'
+  end
+end
+
+require 'nanoc/checking/checks'
diff --git a/lib/nanoc/extra/checking/check.rb b/lib/nanoc/checking/check.rb
similarity index 78%
rename from lib/nanoc/extra/checking/check.rb
rename to lib/nanoc/checking/check.rb
index 186bfb1..8abac87 100644
--- a/lib/nanoc/extra/checking/check.rb
+++ b/lib/nanoc/checking/check.rb
@@ -1,4 +1,4 @@
-module Nanoc::Extra::Checking
+module Nanoc::Checking
   # @api private
   class OutputDirNotFoundError < Nanoc::Int::Errors::Generic
     def initialize(directory_path)
@@ -15,12 +15,12 @@ module Nanoc::Extra::Checking
     def self.create(site)
       output_dir = site.config[:output_dir]
       unless File.exist?(output_dir)
-        raise Nanoc::Extra::Checking::OutputDirNotFoundError.new(output_dir)
+        raise Nanoc::Checking::OutputDirNotFoundError.new(output_dir)
       end
       output_filenames = Dir[output_dir + '/**/*'].select { |f| File.file?(f) }
 
       # FIXME: ugly
-      view_context = site.compiler.create_view_context(Nanoc::Int::DependencyTracker::Null.new)
+      view_context = site.compiler.compilation_context.create_view_context(Nanoc::Int::DependencyTracker::Null.new)
 
       context = {
         items: Nanoc::ItemCollectionWithRepsView.new(site.items, view_context),
@@ -39,7 +39,7 @@ module Nanoc::Extra::Checking
     end
 
     def run
-      raise NotImplementedError.new('Nanoc::Extra::Checking::Check subclasses must implement #run')
+      raise NotImplementedError.new('Nanoc::Checking::Check subclasses must implement #run')
     end
 
     def add_issue(desc, subject: nil)
diff --git a/lib/nanoc/checking/checks.rb b/lib/nanoc/checking/checks.rb
new file mode 100644
index 0000000..fd6a924
--- /dev/null
+++ b/lib/nanoc/checking/checks.rb
@@ -0,0 +1,20 @@
+require_relative 'checks/w3c_validator'
+
+# @api private
+module Nanoc::Checking::Checks
+  autoload 'CSS',           'nanoc/checking/checks/css'
+  autoload 'ExternalLinks', 'nanoc/checking/checks/external_links'
+  autoload 'HTML',          'nanoc/checking/checks/html'
+  autoload 'InternalLinks', 'nanoc/checking/checks/internal_links'
+  autoload 'Stale',         'nanoc/checking/checks/stale'
+  autoload 'MixedContent',  'nanoc/checking/checks/mixed_content'
+
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::CSS',           :css
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::ExternalLinks', :external_links
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::ExternalLinks', :elinks
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::HTML',          :html
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::InternalLinks', :internal_links
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::InternalLinks', :ilinks
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::Stale',         :stale
+  Nanoc::Checking::Check.register '::Nanoc::Checking::Checks::MixedContent',  :mixed_content
+end
diff --git a/lib/nanoc/extra/checking/checks/css.rb b/lib/nanoc/checking/checks/css.rb
similarity index 60%
rename from lib/nanoc/extra/checking/checks/css.rb
rename to lib/nanoc/checking/checks/css.rb
index ae3f901..8accf94 100644
--- a/lib/nanoc/extra/checking/checks/css.rb
+++ b/lib/nanoc/checking/checks/css.rb
@@ -1,6 +1,6 @@
-module ::Nanoc::Extra::Checking::Checks
+module ::Nanoc::Checking::Checks
   # @api private
-  class CSS < ::Nanoc::Extra::Checking::Checks::W3CValidator
+  class CSS < ::Nanoc::Checking::Checks::W3CValidator
     identifier :css
 
     def extension
diff --git a/lib/nanoc/extra/checking/checks/external_links.rb b/lib/nanoc/checking/checks/external_links.rb
similarity index 80%
rename from lib/nanoc/extra/checking/checks/external_links.rb
rename to lib/nanoc/checking/checks/external_links.rb
index 2bb7f5f..e8d92e7 100644
--- a/lib/nanoc/extra/checking/checks/external_links.rb
+++ b/lib/nanoc/checking/checks/external_links.rb
@@ -4,14 +4,16 @@ require 'nokogiri'
 require 'timeout'
 require 'uri'
 
-module ::Nanoc::Extra::Checking::Checks
+module ::Nanoc::Checking::Checks
   # A validator that verifies that all external links point to a location that exists.
   #
   # @api private
-  class ExternalLinks < ::Nanoc::Extra::Checking::Check
+  class ExternalLinks < ::Nanoc::Checking::Check
     identifiers :external_links, :elinks
 
     def run
+      require 'parallel'
+
       # Find all broken external hrefs
       # TODO: de-duplicate this (duplicated in internal links check)
       filenames = output_filenames.select { |f| File.extname(f) == '.html' && !excluded_file?(f) }
@@ -40,45 +42,9 @@ module ::Nanoc::Extra::Checking::Checks
       end
     end
 
-    class ArrayEnumerator
-      def initialize(array)
-        @array = array
-        @index = 0
-        @mutex = Mutex.new
-      end
-
-      def next
-        @mutex.synchronize do
-          @index += 1
-          return @array[@index - 1]
-        end
-      end
-    end
-
     def select_invalid(hrefs)
-      enum = ArrayEnumerator.new(hrefs.sort)
-      mutex = Mutex.new
-      invalid = Set.new
-
-      threads = []
-      10.times do
-        threads << Thread.new do
-          loop do
-            href = enum.next
-            break if href.nil?
-
-            res = validate(href)
-            next unless res
-
-            mutex.synchronize do
-              invalid << res
-            end
-          end
-        end
-      end
-      threads.each(&:join)
-
-      invalid
+      col = Nanoc::Extra::ParallelCollection.new(hrefs, parallelism: 10)
+      col.map { |href| validate(href) }.compact
     end
 
     def validate(href)
@@ -138,7 +104,7 @@ module ::Nanoc::Extra::Checking::Checks
       if last_err
         return Result.new(href, last_err.message)
       else
-        raise 'should not have gotten here'
+        raise Nanoc::Int::Errors::InternalInconsistency, 'last_err cannot be nil'
       end
     end
 
diff --git a/lib/nanoc/extra/checking/checks/html.rb b/lib/nanoc/checking/checks/html.rb
similarity index 61%
rename from lib/nanoc/extra/checking/checks/html.rb
rename to lib/nanoc/checking/checks/html.rb
index 6c5cfa7..8665226 100644
--- a/lib/nanoc/extra/checking/checks/html.rb
+++ b/lib/nanoc/checking/checks/html.rb
@@ -1,6 +1,6 @@
-module ::Nanoc::Extra::Checking::Checks
+module ::Nanoc::Checking::Checks
   # @api private
-  class HTML < ::Nanoc::Extra::Checking::Checks::W3CValidator
+  class HTML < ::Nanoc::Checking::Checks::W3CValidator
     identifier :html
 
     def extension
diff --git a/lib/nanoc/extra/checking/checks/internal_links.rb b/lib/nanoc/checking/checks/internal_links.rb
similarity index 96%
rename from lib/nanoc/extra/checking/checks/internal_links.rb
rename to lib/nanoc/checking/checks/internal_links.rb
index e8ad11d..c7f6592 100644
--- a/lib/nanoc/extra/checking/checks/internal_links.rb
+++ b/lib/nanoc/checking/checks/internal_links.rb
@@ -1,10 +1,10 @@
 require 'uri'
 
-module Nanoc::Extra::Checking::Checks
+module Nanoc::Checking::Checks
   # A check that verifies that all internal links point to a location that exists.
   #
   # @api private
-  class InternalLinks < ::Nanoc::Extra::Checking::Check
+  class InternalLinks < ::Nanoc::Checking::Check
     # Starts the validator. The results will be printed to stdout.
     #
     # Internal links that match a regexp pattern in `@config[:checks][:internal_links][:exclude]` will
diff --git a/lib/nanoc/extra/checking/checks/mixed_content.rb b/lib/nanoc/checking/checks/mixed_content.rb
similarity index 89%
rename from lib/nanoc/extra/checking/checks/mixed_content.rb
rename to lib/nanoc/checking/checks/mixed_content.rb
index 226ffd8..e30899e 100644
--- a/lib/nanoc/extra/checking/checks/mixed_content.rb
+++ b/lib/nanoc/checking/checks/mixed_content.rb
@@ -1,9 +1,9 @@
-module Nanoc::Extra::Checking::Checks
+module Nanoc::Checking::Checks
   # A check that verifies HTML files do not reference external resources with
   # URLs that would trigger "mixed content" warnings.
   #
   # @api private
-  class MixedContent < ::Nanoc::Extra::Checking::Check
+  class MixedContent < ::Nanoc::Checking::Check
     PROTOCOL_PATTERN = /^(\w+):\/\//
 
     def run
diff --git a/lib/nanoc/extra/checking/checks/stale.rb b/lib/nanoc/checking/checks/stale.rb
similarity index 79%
rename from lib/nanoc/extra/checking/checks/stale.rb
rename to lib/nanoc/checking/checks/stale.rb
index 7f65f13..fa10071 100644
--- a/lib/nanoc/extra/checking/checks/stale.rb
+++ b/lib/nanoc/checking/checks/stale.rb
@@ -1,6 +1,6 @@
-module Nanoc::Extra::Checking::Checks
+module Nanoc::Checking::Checks
   # @api private
-  class Stale < ::Nanoc::Extra::Checking::Check
+  class Stale < ::Nanoc::Checking::Check
     def run
       require 'set'
 
@@ -30,7 +30,8 @@ module Nanoc::Extra::Checking::Checks
 
     def pruner
       exclude_config = @config.fetch(:prune, {}).fetch(:exclude, [])
-      @pruner ||= Nanoc::Extra::Pruner.new(@site, exclude: exclude_config)
+      # FIXME: reps=nil is icky
+      @pruner ||= Nanoc::Pruner.new(@config, nil, exclude: exclude_config)
     end
   end
 end
diff --git a/lib/nanoc/extra/checking/checks/w3c_validator.rb b/lib/nanoc/checking/checks/w3c_validator.rb
similarity index 87%
rename from lib/nanoc/extra/checking/checks/w3c_validator.rb
rename to lib/nanoc/checking/checks/w3c_validator.rb
index 2b6c57e..1ebc3be 100644
--- a/lib/nanoc/extra/checking/checks/w3c_validator.rb
+++ b/lib/nanoc/checking/checks/w3c_validator.rb
@@ -1,6 +1,6 @@
-module ::Nanoc::Extra::Checking::Checks
+module ::Nanoc::Checking::Checks
   # @api private
-  class W3CValidator < ::Nanoc::Extra::Checking::Check
+  class W3CValidator < ::Nanoc::Checking::Check
     def run
       require 'w3c_validators'
 
diff --git a/lib/nanoc/extra/checking/dsl.rb b/lib/nanoc/checking/dsl.rb
similarity index 68%
rename from lib/nanoc/extra/checking/dsl.rb
rename to lib/nanoc/checking/dsl.rb
index 0df8c7d..f6218e6 100644
--- a/lib/nanoc/extra/checking/dsl.rb
+++ b/lib/nanoc/checking/dsl.rb
@@ -1,11 +1,12 @@
-module Nanoc::Extra::Checking
+module Nanoc::Checking
   # @api private
   class DSL
     attr_reader :deploy_checks
 
     def self.from_file(filename)
       dsl = new
-      dsl.instance_eval(File.read(filename), filename)
+      absolute_filename = File.expand_path(filename)
+      dsl.instance_eval(File.read(filename), absolute_filename)
       dsl
     end
 
@@ -14,7 +15,7 @@ module Nanoc::Extra::Checking
     end
 
     def check(identifier, &block)
-      klass = Class.new(::Nanoc::Extra::Checking::Check)
+      klass = Class.new(::Nanoc::Checking::Check)
       klass.send(:define_method, :run, &block)
       klass.send(:identifier, identifier)
     end
diff --git a/lib/nanoc/extra/checking/issue.rb b/lib/nanoc/checking/issue.rb
similarity index 90%
rename from lib/nanoc/extra/checking/issue.rb
rename to lib/nanoc/checking/issue.rb
index b5dfdb4..a53a57a 100644
--- a/lib/nanoc/extra/checking/issue.rb
+++ b/lib/nanoc/checking/issue.rb
@@ -1,4 +1,4 @@
-module Nanoc::Extra::Checking
+module Nanoc::Checking
   # @api private
   class Issue
     attr_reader :description
diff --git a/lib/nanoc/extra/checking/runner.rb b/lib/nanoc/checking/runner.rb
similarity index 94%
rename from lib/nanoc/extra/checking/runner.rb
rename to lib/nanoc/checking/runner.rb
index 75ed526..bde9dba 100644
--- a/lib/nanoc/extra/checking/runner.rb
+++ b/lib/nanoc/checking/runner.rb
@@ -1,4 +1,4 @@
-module Nanoc::Extra::Checking
+module Nanoc::Checking
   # Runner is reponsible for running issue checks.
   #
   # @api private
@@ -67,7 +67,7 @@ module Nanoc::Extra::Checking
       unless @dsl_loaded
         @dsl =
           if dsl_present?
-            Nanoc::Extra::Checking::DSL.from_file(checks_filename)
+            Nanoc::Checking::DSL.from_file(checks_filename)
           else
             nil
           end
@@ -93,12 +93,12 @@ module Nanoc::Extra::Checking
     end
 
     def all_check_classes
-      Nanoc::Extra::Checking::Check.all.map(&:last).uniq
+      Nanoc::Checking::Check.all.map(&:last).uniq
     end
 
     def check_classes_named(n)
       n.map do |a|
-        klass = Nanoc::Extra::Checking::Check.named(a)
+        klass = Nanoc::Checking::Check.named(a)
         raise Nanoc::Int::Errors::GenericTrivial, "Unknown check: #{a}" if klass.nil?
         klass
       end
diff --git a/lib/nanoc/cli.rb b/lib/nanoc/cli.rb
index 420ce62..fd78a79 100644
--- a/lib/nanoc/cli.rb
+++ b/lib/nanoc/cli.rb
@@ -19,8 +19,6 @@ module Nanoc::CLI
   autoload 'ErrorHandler',        'nanoc/cli/error_handler'
 
   # @return [Boolean] true if debug output is enabled, false if not
-  #
-  # @since 3.2.0
   def self.debug?
     @debug || false
   end
@@ -29,8 +27,6 @@ module Nanoc::CLI
   #   false if it should not
   #
   # @return [void]
-  #
-  # @since 3.2.0
   def self.debug=(boolean)
     @debug = boolean
   end
diff --git a/lib/nanoc/cli/cleaning_stream.rb b/lib/nanoc/cli/cleaning_stream.rb
index dd50a9c..8d13a59 100644
--- a/lib/nanoc/cli/cleaning_stream.rb
+++ b/lib/nanoc/cli/cleaning_stream.rb
@@ -56,6 +56,11 @@ module Nanoc::CLI
       @cached_is_tty ||= @stream.tty?
     end
 
+    # @see IO#isatty
+    def isatty
+      tty?
+    end
+
     # @see IO#flush
     def flush
       _nanoc_swallow_broken_pipe_errors_while do
@@ -133,14 +138,16 @@ module Nanoc::CLI
     end
 
     # @see ARGF.set_encoding
+    # rubocop:disable Style/AccessorMethodName
     def set_encoding(*args)
       @stream.set_encoding(*args)
     end
+    # rubocop:enable Style/AccessorMethodName
 
     protected
 
     def _nanoc_clean(s)
-      @stream_cleaners.reduce(s.to_s) { |a, e| e.clean(a) }
+      @stream_cleaners.reduce(s.to_s) { |acc, elem| elem.clean(acc) }
     end
 
     def _nanoc_swallow_broken_pipe_errors_while
diff --git a/lib/nanoc/cli/command_runner.rb b/lib/nanoc/cli/command_runner.rb
index d7b92f4..0b65b63 100644
--- a/lib/nanoc/cli/command_runner.rb
+++ b/lib/nanoc/cli/command_runner.rb
@@ -45,8 +45,8 @@ module Nanoc::CLI
     #
     # @return [void]
     def load_site(preprocess: false)
-      print 'Loading site… '
-      $stdout.flush
+      $stderr.print 'Loading site… '
+      $stderr.flush
 
       if site.nil?
         raise ::Nanoc::Int::Errors::GenericTrivial, 'The current working directory does not seem to be a Nanoc site.'
@@ -56,7 +56,7 @@ module Nanoc::CLI
         site.compiler.action_provider.preprocess(site)
       end
 
-      puts 'done'
+      $stderr.puts 'done'
     end
 
     # @return [Boolean] true if debug output is enabled, false if not
@@ -65,12 +65,5 @@ module Nanoc::CLI
     def debug?
       Nanoc::CLI.debug?
     end
-
-    protected
-
-    # @return [Array] The compilation stack.
-    def stack
-      (site && site.compiler.stack) || []
-    end
   end
 end
diff --git a/lib/nanoc/cli/commands/check.rb b/lib/nanoc/cli/commands/check.rb
index 67f6b87..0c37255 100644
--- a/lib/nanoc/cli/commands/check.rb
+++ b/lib/nanoc/cli/commands/check.rb
@@ -14,7 +14,7 @@ module Nanoc::CLI::Commands
       validate_options_and_arguments
       load_site(preprocess: true)
 
-      runner = Nanoc::Extra::Checking::Runner.new(site)
+      runner = Nanoc::Checking::Runner.new(site)
 
       if options[:list]
         runner.list_checks
diff --git a/lib/nanoc/cli/commands/compile.rb b/lib/nanoc/cli/commands/compile.rb
index 414e14b..3cc68d4 100644
--- a/lib/nanoc/cli/commands/compile.rb
+++ b/lib/nanoc/cli/commands/compile.rb
@@ -2,17 +2,6 @@ usage 'compile [options]'
 summary 'compile items of this site'
 description <<-EOS
 Compile all items of the current site.
-
-The compile command will show all items of the site as they are processed. The time spent compiling the item will be printed, as well as a status message, which can be one of the following:
-
-CREATED - The compiled item did not yet exist and has been created
-
-UPDATED - The compiled item did already exist and has been modified
-
-IDENTICAL - The item was deemed outdated and has been recompiled, but the compiled version turned out to be identical to the already existing version
-
-SKIP - The item was deemed not outdated and was therefore not recompiled
-
 EOS
 flag nil, :profile, 'profile compilation' if Nanoc::Feature.enabled?(Nanoc::Feature::PROFILER)
 
@@ -24,8 +13,7 @@ module Nanoc::CLI::Commands
     #
     # @abstract Subclasses must override {#start} and may override {#stop}.
     class Listener
-      def initialize(*)
-      end
+      def initialize(*); end
 
       # @param [Nanoc::CLI::CommandRunner] command_runner The command runner for this listener
       #
@@ -48,8 +36,7 @@ module Nanoc::CLI::Commands
       # Stops the listener. The default implementation removes self from all notification center observers.
       #
       # @return [void]
-      def stop
-      end
+      def stop; end
 
       # @api private
       def start_safely
@@ -164,19 +151,55 @@ module Nanoc::CLI::Commands
 
       # @param [Enumerable<Nanoc::Int::ItemRep>] reps
       def initialize(reps:)
-        @times = {}
+        # rep ->
+        #   filter_name ->
+        #     accum -> 0.0
+        #     last_start -> nil
+        @times_per_rep = {}
 
         @reps = reps
       end
 
       # @see Listener#start
       def start
-        Nanoc::Int::NotificationCenter.on(:filtering_started) do |_rep, filter_name|
-          @times[filter_name] ||= []
-          @times[filter_name] << { start: Time.now }
+        Nanoc::Int::NotificationCenter.on(:filtering_started) do |rep, filter_name|
+          @times_per_rep[rep] ||= {}
+          @times_per_rep[rep][filter_name] ||= {}
+
+          @times_per_rep[rep][filter_name][:last_start] = Time.now
+          @times_per_rep[rep][filter_name][:accum] ||= []
+          @times_per_rep[rep][filter_name][:suspended] = false
         end
-        Nanoc::Int::NotificationCenter.on(:filtering_ended) do |_rep, filter_name|
-          @times[filter_name].last[:stop] = Time.now
+
+        Nanoc::Int::NotificationCenter.on(:filtering_ended) do |rep, filter_name|
+          times = @times_per_rep[rep][filter_name]
+          last_start = @times_per_rep[rep][filter_name][:last_start]
+
+          times[:accum] << (Time.now - last_start)
+          @times_per_rep[rep][filter_name].delete(:last_start)
+        end
+
+        Nanoc::Int::NotificationCenter.on(:compilation_suspended) do |rep, _exception|
+          @times_per_rep.fetch(rep, {}).each do |_filter_name, times|
+            if times[:last_start]
+              times[:accum] << (Time.now - times[:last_start])
+              times.delete(:last_start)
+              times[:suspended] = true
+
+              break
+            end
+          end
+        end
+
+        Nanoc::Int::NotificationCenter.on(:compilation_started) do |rep|
+          @times_per_rep.fetch(rep, {}).each do |filter_name, times|
+            if times[:suspended]
+              @times_per_rep[rep][filter_name][:last_start] = Time.now
+              times[:suspended] = false
+
+              break
+            end
+          end
         end
       end
 
@@ -217,7 +240,7 @@ module Nanoc::CLI::Commands
         # Calculate stats
         count = samples.size
         min   = samples.min
-        tot   = samples.reduce(0) { |a, e| a + e }
+        tot   = samples.reduce(0) { |acc, elem| acc + elem }
         avg   = tot / count
         max   = samples.max
 
@@ -236,55 +259,17 @@ module Nanoc::CLI::Commands
       def durations_per_filter
         @_durations_per_filter ||= begin
           result = {}
-          @times.keys.each do |filter_name|
-            durations = durations_for_filter(filter_name)
-            if durations
-              result[filter_name] = durations
-            end
-          end
-          result
-        end
-      end
 
-      def durations_for_filter(filter_name)
-        result = []
-        @times[filter_name].each do |sample|
-          if sample[:start] && sample[:stop]
-            result << sample[:stop] - sample[:start]
+          @times_per_rep.each do |_rep, times_per_filter|
+            times_per_filter.each do |filter_name, data|
+              result[filter_name] ||= []
+              result[filter_name].concat(data[:accum])
+            end
           end
-        end
-        result
-      end
-    end
-
-    # Controls garbage collection so that it only occurs once every 20 items
-    class GCController < Listener
-      # @see Listener#enable_for?
-      def self.enable_for?(_command_runner)
-        !ENV.key?('TRAVIS')
-      end
 
-      def initialize(*)
-        @gc_count = 0
-      end
-
-      # @see Listener#start
-      def start
-        Nanoc::Int::NotificationCenter.on(:compilation_started) do |_rep|
-          if (@gc_count % 20).zero?
-            GC.enable
-            GC.start
-            GC.disable
-          end
-          @gc_count += 1
+          result
         end
       end
-
-      # @see Listener#stop
-      def stop
-        super
-        GC.enable
-      end
     end
 
     # Prints debug information (compilation started/ended, filtering started/ended, …)
@@ -303,7 +288,7 @@ module Nanoc::CLI::Commands
           puts "*** Ended compilation of #{rep.inspect}"
           puts
         end
-        Nanoc::Int::NotificationCenter.on(:compilation_failed) do |rep, e|
+        Nanoc::Int::NotificationCenter.on(:compilation_suspended) do |rep, e|
           puts "*** Suspended compilation of #{rep.inspect}: #{e.message}"
         end
         Nanoc::Int::NotificationCenter.on(:cached_content_used) do |rep|
@@ -325,17 +310,26 @@ module Nanoc::CLI::Commands
     class FileActionPrinter < Listener
       def initialize(reps:)
         @start_times = {}
+        @acc_durations = {}
 
         @reps = reps
       end
 
       # @see Listener#start
       def start
-        Nanoc::Int::NotificationCenter.on(:compilation_started) do |rep|
-          @start_times[rep.raw_path] = Time.now
+        Nanoc::Int::NotificationCenter.on(:compilation_started, self) do |rep|
+          @start_times[rep] = Time.now
+          @acc_durations[rep] ||= 0.0
+        end
+
+        Nanoc::Int::NotificationCenter.on(:compilation_suspended, self) do |rep|
+          @acc_durations[rep] += Time.now - @start_times[rep]
         end
-        Nanoc::Int::NotificationCenter.on(:rep_written) do |_rep, path, is_created, is_modified|
-          duration = path && @start_times[path] ? Time.now - @start_times[path] : nil
+
+        Nanoc::Int::NotificationCenter.on(:rep_written, self) do |rep, path, is_created, is_modified|
+          @acc_durations[rep] += Time.now - @start_times[rep]
+          duration = @acc_durations[rep]
+
           action =
             if is_created then :create
             elsif is_modified then :update
@@ -353,6 +347,11 @@ module Nanoc::CLI::Commands
       # @see Listener#stop
       def stop
         super
+
+        Nanoc::Int::NotificationCenter.remove(:compilation_started, self)
+        Nanoc::Int::NotificationCenter.remove(:compilation_suspended, self)
+        Nanoc::Int::NotificationCenter.remove(:rep_written, self)
+
         @reps.select { |r| !r.compiled? }.each do |rep|
           rep.raw_paths.each do |_snapshot_name, raw_path|
             log(:low, :skip, raw_path, nil)
@@ -415,12 +414,11 @@ module Nanoc::CLI::Commands
 
     def default_listener_classes
       [
+        Nanoc::CLI::Commands::Compile::StackProfProfiler,
         Nanoc::CLI::Commands::Compile::DiffGenerator,
         Nanoc::CLI::Commands::Compile::DebugPrinter,
         Nanoc::CLI::Commands::Compile::TimingRecorder,
-        Nanoc::CLI::Commands::Compile::GCController,
         Nanoc::CLI::Commands::Compile::FileActionPrinter,
-        Nanoc::CLI::Commands::Compile::StackProfProfiler,
       ]
     end
 
@@ -445,7 +443,7 @@ module Nanoc::CLI::Commands
     end
 
     def teardown_listeners
-      @listeners.each(&:stop_safely)
+      @listeners.reverse_each(&:stop_safely)
     end
 
     def reps
diff --git a/lib/nanoc/cli/commands/create-site.rb b/lib/nanoc/cli/commands/create-site.rb
index 0c0eae5..8891319 100644
--- a/lib/nanoc/cli/commands/create-site.rb
+++ b/lib/nanoc/cli/commands/create-site.rb
@@ -278,14 +278,13 @@ EOS
     <div id="sidebar">
       <h2>Documentation</h2>
       <ul>
-        <li><a href="http://nanoc.ws/about/">About</a></li>
         <li><a href="http://nanoc.ws/doc/">Documentation</a></li>
         <li><a href="http://nanoc.ws/doc/tutorial/">Tutorial</a></li>
       </ul>
       <h2>Community</h2>
       <ul>
         <li><a href="http://groups.google.com/group/nanoc/">Discussion group</a></li>
-        <li><a href="irc://chat.freenode.net/#nanoc">IRC channel</a></li>
+        <li><a href="https://gitter.im/nanoc/nanoc">Gitter channel</a></li>
         <li><a href="http://nanoc.ws/contributing/">Contributing</a></li>
       </ul>
     </div>
diff --git a/lib/nanoc/cli/commands/deploy.rb b/lib/nanoc/cli/commands/deploy.rb
index 6547a64..02c4110 100644
--- a/lib/nanoc/cli/commands/deploy.rb
+++ b/lib/nanoc/cli/commands/deploy.rb
@@ -27,7 +27,7 @@ module Nanoc::CLI::Commands
     private
 
     def list_deployers
-      deployers      = Nanoc::Int::PluginRegistry.instance.find_all(Nanoc::Extra::Deployer)
+      deployers      = Nanoc::Int::PluginRegistry.instance.find_all(Nanoc::Deploying::Deployer)
       deployer_names = deployers.keys.sort_by(&:to_s)
       puts 'Available deployers:'
       deployer_names.each do |name|
@@ -85,7 +85,7 @@ module Nanoc::CLI::Commands
     end
 
     def check
-      runner = Nanoc::Extra::Checking::Runner.new(site)
+      runner = Nanoc::Checking::Runner.new(site)
       if runner.dsl_present?
         puts 'Running issue checks…'
         is_success = runner.run_for_deploy
@@ -105,13 +105,13 @@ module Nanoc::CLI::Commands
     end
 
     def deployer_class_for_config(config)
-      names = Nanoc::Extra::Deployer.all.keys
+      names = Nanoc::Deploying::Deployer.all.keys
       name = config.fetch(:kind) do
         $stderr.puts 'Warning: The specified deploy target does not have a kind attribute. Assuming rsync.'
         'rsync'
       end
 
-      deployer_class = Nanoc::Extra::Deployer.named(name)
+      deployer_class = Nanoc::Deploying::Deployer.named(name)
       if deployer_class.nil?
         raise Nanoc::Int::Errors::GenericTrivial, "The specified deploy target has an unrecognised kind “#{name}” (expected one of #{names.join(', ')})."
       end
diff --git a/lib/nanoc/cli/commands/nanoc.rb b/lib/nanoc/cli/commands/nanoc.rb
index 0372018..ad70f55 100644
--- a/lib/nanoc/cli/commands/nanoc.rb
+++ b/lib/nanoc/cli/commands/nanoc.rb
@@ -10,6 +10,10 @@ opt :d, :debug, 'enable debugging' do
   Nanoc::CLI.debug = true
 end
 
+opt :e, :env, 'set environment', argument: :required do |value|
+  ENV.store('NANOC_ENV', value)
+end
+
 opt :h, :help, 'show the help message and quit' do |_value, cmd|
   puts cmd.help
   exit 0
diff --git a/lib/nanoc/cli/commands/prune.rb b/lib/nanoc/cli/commands/prune.rb
index 4de401f..e8d2e71 100644
--- a/lib/nanoc/cli/commands/prune.rb
+++ b/lib/nanoc/cli/commands/prune.rb
@@ -19,9 +19,9 @@ module Nanoc::CLI::Commands
       site.compiler.build_reps
 
       if options.key?(:yes)
-        Nanoc::Extra::Pruner.new(site, exclude: prune_config_exclude).run
+        Nanoc::Pruner.new(site.config, site.compiler.reps, exclude: prune_config_exclude).run
       elsif options.key?(:'dry-run')
-        Nanoc::Extra::Pruner.new(site, exclude: prune_config_exclude, dry_run: true).run
+        Nanoc::Pruner.new(site.config, site.compiler.reps, exclude: prune_config_exclude, dry_run: true).run
       else
         $stderr.puts 'WARNING: Since the prune command is a destructive command, it requires an additional --yes flag in order to work.'
         $stderr.puts
diff --git a/lib/nanoc/cli/commands/shell.rb b/lib/nanoc/cli/commands/shell.rb
index 6a3127b..77521dc 100644
--- a/lib/nanoc/cli/commands/shell.rb
+++ b/lib/nanoc/cli/commands/shell.rb
@@ -21,11 +21,30 @@ module Nanoc::CLI::Commands
       self.class.env_for_site(site)
     end
 
+    def self.reps_for(site)
+      Nanoc::Int::ItemRepRepo.new.tap do |reps|
+        action_provider = Nanoc::Int::ActionProvider.named(:rule_dsl).for(site)
+        builder = Nanoc::Int::ItemRepBuilder.new(site, action_provider, reps)
+        builder.run
+      end
+    end
+
+    def self.view_context_for(site)
+      Nanoc::ViewContext.new(
+        reps: reps_for(site),
+        items: site.items,
+        dependency_tracker: Nanoc::Int::DependencyTracker::Null.new,
+        compilation_context: nil,
+      )
+    end
+
     def self.env_for_site(site)
+      view_context = view_context_for(site)
+
       {
-        items: Nanoc::ItemCollectionWithRepsView.new(site.items, nil),
-        layouts: Nanoc::LayoutCollectionView.new(site.layouts, nil),
-        config: Nanoc::ConfigView.new(site.config, nil),
+        items: Nanoc::ItemCollectionWithRepsView.new(site.items, view_context),
+        layouts: Nanoc::LayoutCollectionView.new(site.layouts, view_context),
+        config: Nanoc::ConfigView.new(site.config, view_context),
       }
     end
   end
diff --git a/lib/nanoc/cli/commands/show-data.rb b/lib/nanoc/cli/commands/show-data.rb
index 6a4cb32..929c8f1 100644
--- a/lib/nanoc/cli/commands/show-data.rb
+++ b/lib/nanoc/cli/commands/show-data.rb
@@ -20,6 +20,9 @@ module Nanoc::CLI::Commands
       compiler.load_stores
       dependency_store = compiler.dependency_store
 
+      # Build reps
+      compiler.build_reps
+
       # Print data
       print_item_dependencies(items, dependency_store)
       print_item_rep_paths(items)
@@ -59,11 +62,23 @@ module Nanoc::CLI::Commands
     def print_item_dependencies(items, dependency_store)
       print_header('Item dependencies')
 
+      puts 'Legend:'
+      puts '  r = dependency on raw content'
+      puts '  a = dependency on attributes'
+      puts '  c = dependency on compiled content'
+      puts '  p = dependency on the path'
+      puts
+
       sorted_with_prev(items) do |item, prev|
         puts if prev
         puts "item #{item.identifier} depends on:"
-        predecessors = dependency_store.objects_causing_outdatedness_of(item).sort_by { |i| i ? i.identifier : '' }
-        predecessors.each do |pred|
+        dependencies =
+          dependency_store
+          .dependencies_causing_outdatedness_of(item)
+          .sort_by { |dep| dep.from ? dep.from.identifier : '' }
+        dependencies.each do |dep|
+          pred = dep.from
+
           type =
             case pred
             when Nanoc::Int::Layout
@@ -74,13 +89,19 @@ module Nanoc::CLI::Commands
               'item'
             end
 
+          props = ''
+          props << (dep.props.raw_content? ? 'r' : '_')
+          props << (dep.props.attributes? ? 'a' : '_')
+          props << (dep.props.compiled_content? ? 'c' : '_')
+          props << (dep.props.path? ? 'p' : '_')
+
           if pred
-            puts "  [ #{format '%6s', type} ] #{pred.identifier}"
+            puts "  [ #{format '%6s', type} ] (#{props}) #{pred.identifier}"
           else
             puts '  ( removed item )'
           end
         end
-        puts '  (nothing)' if predecessors.empty?
+        puts '  (nothing)' if dependencies.empty?
       end
     end
 
diff --git a/lib/nanoc/cli/commands/show-plugins.rb b/lib/nanoc/cli/commands/show-plugins.rb
index 7b4911c..c9d15e7 100644
--- a/lib/nanoc/cli/commands/show-plugins.rb
+++ b/lib/nanoc/cli/commands/show-plugins.rb
@@ -72,14 +72,14 @@ module Nanoc::CLI::Commands
     PLUGIN_CLASS_ORDER = [
       Nanoc::Filter,
       Nanoc::DataSource,
-      Nanoc::Extra::Deployer,
-    ].freeze unless defined? PLUGIN_CLASS_ORDER
+      Nanoc::Deploying::Deployer,
+    ].freeze
 
     PLUGIN_CLASSES = {
       Nanoc::Filter          => 'Filters',
       Nanoc::DataSource      => 'Data Sources',
-      Nanoc::Extra::Deployer => 'Deployers',
-    }.freeze unless defined? PLUGIN_CLASSES
+      Nanoc::Deploying::Deployer => 'Deployers',
+    }.freeze
 
     def name_for_plugin_class(klass)
       PLUGIN_CLASSES[klass]
diff --git a/lib/nanoc/cli/commands/show-rules.rb b/lib/nanoc/cli/commands/show-rules.rb
index 58d95ea..8cb8e43 100644
--- a/lib/nanoc/cli/commands/show-rules.rb
+++ b/lib/nanoc/cli/commands/show-rules.rb
@@ -11,7 +11,10 @@ module Nanoc::CLI::Commands
       load_site
 
       @c = Nanoc::CLI::ANSIStringColorizer
-      @reps = site.compiler.reps
+
+      compiler = site.compiler
+      compiler.build_reps
+      @reps = compiler.reps
 
       action_provider = site.compiler.action_provider
       unless action_provider.respond_to?(:rules_collection)
diff --git a/lib/nanoc/cli/commands/view.rb b/lib/nanoc/cli/commands/view.rb
index f625755..2072bab 100644
--- a/lib/nanoc/cli/commands/view.rb
+++ b/lib/nanoc/cli/commands/view.rb
@@ -44,7 +44,9 @@ module Nanoc::CLI::Commands
         use Rack::ShowExceptions
         use Rack::Lint
         use Rack::Head
-        use Adsf::Rack::IndexFileFinder, root: site.config[:output_dir]
+        use Adsf::Rack::IndexFileFinder,
+            root: site.config[:output_dir],
+            index_filenames: site.config[:index_filenames]
         run Rack::File.new(site.config[:output_dir])
       end.to_app
 
diff --git a/lib/nanoc/cli/error_handler.rb b/lib/nanoc/cli/error_handler.rb
index 79c036c..0f075ad 100644
--- a/lib/nanoc/cli/error_handler.rb
+++ b/lib/nanoc/cli/error_handler.rb
@@ -113,7 +113,7 @@ module Nanoc::CLI
 
       # Sections
       write_error_message(stream, error)
-      write_compilation_stack(stream, error)
+      write_item_rep(stream, error)
       write_stack_trace(stream, error)
 
       # Issue link
@@ -133,7 +133,7 @@ module Nanoc::CLI
 
       # Sections
       write_error_message(stream, error, verbose: true)
-      write_compilation_stack(stream, error, verbose: true)
+      write_item_rep(stream, error, verbose: true)
       write_stack_trace(stream, error, verbose: true)
       write_version_information(stream, verbose: true)
       write_system_information(stream, verbose: true)
@@ -161,11 +161,6 @@ module Nanoc::CLI
       site && site.compiler
     end
 
-    # @return [Array] The current compilation stack
-    def stack
-      (compiler && compiler.stack) || []
-    end
-
     # @return [Hash<String, Array>] A hash containing the gem names as keys and gem versions as value
     def gems_and_versions
       gems = {}
@@ -216,6 +211,8 @@ module Nanoc::CLI
     #
     # @return [String] The resolution for the given error
     def resolution_for(error)
+      error = unwrap_error(error)
+
       case error
       when LoadError
         # Get gem name
@@ -257,30 +254,28 @@ module Nanoc::CLI
     def write_error_message(stream, error, verbose: false)
       write_section_header(stream, 'Message', verbose: verbose)
 
+      error = unwrap_error(error)
+
       stream.puts "#{error.class}: #{error.message}"
       resolution = resolution_for(error)
       stream.puts resolution.to_s if resolution
     end
 
-    def write_compilation_stack(stream, _error, verbose: false)
-      write_section_header(stream, 'Compilation stack', verbose: verbose)
+    def write_item_rep(stream, error, verbose: false)
+      return unless error.is_a?(Nanoc::Int::Errors::CompilationError)
 
-      if stack.empty?
-        stream.puts '  (empty)'
-      else
-        stack.reverse_each do |obj|
-          if obj.is_a?(Nanoc::Int::ItemRep)
-            stream.puts "  - [item]   #{obj.item.identifier} (rep #{obj.name})"
-          else # layout
-            stream.puts "  - [layout] #{obj.identifier}"
-          end
-        end
-      end
+      write_section_header(stream, 'Item being compiled', verbose: verbose)
+
+      item_rep = error.item_rep
+      stream.puts "Item identifier: #{item_rep.item.identifier}"
+      stream.puts "Item rep name:   #{item_rep.name.inspect}"
     end
 
     def write_stack_trace(stream, error, verbose: false)
       write_section_header(stream, 'Stack trace', verbose: verbose)
 
+      error = unwrap_error(error)
+
       count = verbose ? -1 : 10
       error.backtrace[0...count].each_with_index do |item, index|
         stream.puts "  #{index}. #{item}"
@@ -330,5 +325,14 @@ module Nanoc::CLI
         stream.puts "  #{index}. #{i}"
       end
     end
+
+    def unwrap_error(e)
+      case e
+      when Nanoc::Int::Errors::CompilationError
+        e.unwrap
+      else
+        e
+      end
+    end
   end
 end
diff --git a/lib/nanoc/data_sources/filesystem.rb b/lib/nanoc/data_sources/filesystem.rb
index 5fa6bff..5fdce86 100644
--- a/lib/nanoc/data_sources/filesystem.rb
+++ b/lib/nanoc/data_sources/filesystem.rb
@@ -47,12 +47,10 @@ module Nanoc::DataSources
   # @api private
   class Filesystem < Nanoc::DataSource
     # See {Nanoc::DataSource#up}.
-    def up
-    end
+    def up; end
 
     # See {Nanoc::DataSource#down}.
-    def down
-    end
+    def down; end
 
     def content_dir_name
       config.fetch(:content_dir, 'content')
@@ -76,11 +74,12 @@ module Nanoc::DataSources
 
     class ProtoDocument
       attr_reader :attributes
-      attr_reader :checksum_data
+      attr_reader :content_checksum_data
+      attr_reader :attributes_checksum_data
       attr_reader :is_binary
       alias binary? is_binary
 
-      def initialize(is_binary:, content: nil, filename: nil, attributes:, checksum_data: nil)
+      def initialize(is_binary:, content: nil, filename: nil, attributes:, content_checksum_data: nil, attributes_checksum_data: nil)
         if content.nil? && filename.nil?
           raise ArgumentError, '#initialize needs at least content or filename'
         end
@@ -89,7 +88,8 @@ module Nanoc::DataSources
         @content = content
         @filename = filename
         @attributes = attributes
-        @checksum_data = checksum_data
+        @content_checksum_data = content_checksum_data
+        @attributes_checksum_data = attributes_checksum_data
       end
 
       def content
@@ -117,7 +117,7 @@ module Nanoc::DataSources
 
         ProtoDocument.new(is_binary: true, filename: content_filename, attributes: meta)
       elsif is_binary && klass == Nanoc::Int::Layout
-        raise "The layout file '#{content_filename}' is a binary file, but layouts can only be textual"
+        raise Errors::BinaryLayout.new(content_filename)
       else
         parse_result = parse(content_filename, meta_filename)
 
@@ -125,7 +125,8 @@ module Nanoc::DataSources
           is_binary: false,
           content: parse_result.content,
           attributes: parse_result.attributes,
-          checksum_data: "content=#{parse_result.content},meta=#{parse_result.attributes_data}",
+          content_checksum_data: parse_result.content,
+          attributes_checksum_data: parse_result.attributes_data,
         )
       end
     end
@@ -157,7 +158,13 @@ module Nanoc::DataSources
           attributes = attributes_for(proto_doc, content_filename, meta_filename)
           identifier = identifier_for(content_filename, meta_filename, dir_name)
 
-          res << klass.new(content, attributes, identifier, checksum_data: proto_doc.checksum_data)
+          res << klass.new(
+            content,
+            attributes,
+            identifier,
+            content_checksum_data: proto_doc.content_checksum_data,
+            attributes_checksum_data: proto_doc.attributes_checksum_data,
+          )
         end
       end
 
@@ -232,11 +239,11 @@ module Nanoc::DataSources
 
         # Check number of files per type
         unless [0, 1].include?(meta_filenames.size)
-          raise "Found #{meta_filenames.size} meta files for #{basename}; expected 0 or 1"
+          raise Errors::MultipleMetaFiles.new(meta_filenames, basename)
         end
         unless config[:identifier_type] == 'full'
           unless [0, 1].include?(content_filenames.size)
-            raise "Found #{content_filenames.size} content files for #{basename}; expected 0 or 1"
+            raise Errors::MultipleContentFiles.new(meta_filenames, basename)
           end
         end
 
@@ -252,7 +259,7 @@ module Nanoc::DataSources
 
     # Returns all files in the given directory and directories below it.
     def all_files_in(dir_name)
-      Nanoc::Extra::FilesystemTools.all_files_in(dir_name, config[:extra_files])
+      Nanoc::DataSources::Filesystem::Tools.all_files_in(dir_name, config[:extra_files])
     end
 
     # Returns the filename for the given base filename and the extension.
@@ -345,7 +352,7 @@ module Nanoc::DataSources
 
       pieces = data.split(/^(-{5}|-{3})[ \t]*\r?\n?/, 3)
       if pieces.size < 4
-        raise "The file '#{content_filename}' appears to start with a metadata section (three or five dashes at the top) but it does not seem to be in the correct format."
+        raise Errors::InvalidFormat.new(content_filename)
       end
 
       meta = parse_metadata(pieces[2], content_filename)
@@ -358,8 +365,8 @@ module Nanoc::DataSources
     def parse_metadata(data, filename)
       begin
         meta = YAML.load(data) || {}
-      rescue Exception => e
-        raise "Could not parse YAML for #{filename}: #{e.message}"
+      rescue => e
+        raise Errors::UnparseableMetadata.new(filename, e)
       end
 
       verify_meta(meta, filename)
@@ -379,16 +386,10 @@ module Nanoc::DataSources
       end
     end
 
-    class InvalidMetadataError < Nanoc::Error
-      def initialize(filename, klass)
-        super("The file #{filename} has invalid metadata (expected key-value pairs, found #{klass} instead)")
-      end
-    end
-
     def verify_meta(meta, filename)
       return if meta.is_a?(Hash)
 
-      raise InvalidMetadataError.new(filename, meta.class)
+      raise Errors::InvalidMetadata.new(filename, meta.class)
     end
 
     # Reads the content of the file with the given name and returns a string
@@ -400,7 +401,7 @@ module Nanoc::DataSources
       begin
         data = File.read(filename)
       rescue => e
-        raise "Could not read #{filename}: #{e.inspect}"
+        raise Errors::FileUnreadable.new(filename, e)
       end
 
       # Fix
@@ -415,11 +416,11 @@ module Nanoc::DataSources
         begin
           data.encode!('UTF-8')
         rescue
-          raise_encoding_error(filename, original_encoding)
+          raise Errors::InvalidEncoding.new(filename, original_encoding)
         end
 
         unless data.valid_encoding?
-          raise_encoding_error(filename, original_encoding)
+          raise Errors::InvalidEncoding.new(filename, original_encoding)
         end
       end
 
@@ -428,10 +429,8 @@ module Nanoc::DataSources
 
       data
     end
-
-    # Raises an invalid encoding error for the given filename and encoding.
-    def raise_encoding_error(filename, encoding)
-      raise "Could not read #{filename} because the file is not valid #{encoding}."
-    end
   end
 end
+
+require_relative 'filesystem/tools'
+require_relative 'filesystem/errors'
diff --git a/lib/nanoc/data_sources/filesystem/errors.rb b/lib/nanoc/data_sources/filesystem/errors.rb
new file mode 100644
index 0000000..99e3ed6
--- /dev/null
+++ b/lib/nanoc/data_sources/filesystem/errors.rb
@@ -0,0 +1,55 @@
+class Nanoc::DataSources::Filesystem < Nanoc::DataSource
+  # @api private
+  module Errors
+    class Generic < ::Nanoc::Error
+    end
+
+    class BinaryLayout < Generic
+      def initialize(content_filename)
+        super("The layout file '#{content_filename}' is a binary file, but layouts can only be textual")
+      end
+    end
+
+    class MultipleMetaFiles < Generic
+      def initialize(meta_filenames, basename)
+        super("Found #{meta_filenames.size} meta files for #{basename}; expected 0 or 1")
+      end
+    end
+
+    class MultipleContentFiles < Generic
+      def initialize(content_filenames, basename)
+        super("Found #{content_filenames.size} content files for #{basename}; expected 0 or 1")
+      end
+    end
+
+    class InvalidFormat < Generic
+      def initialize(content_filename)
+        super("The file '#{content_filename}' appears to start with a metadata section (three or five dashes at the top) but it does not seem to be in the correct format.")
+      end
+    end
+
+    class UnparseableMetadata < Generic
+      def initialize(filename, error)
+        super("Could not parse metadata for #{filename}: #{error.message}")
+      end
+    end
+
+    class InvalidMetadata < Generic
+      def initialize(filename, klass)
+        super("The file #{filename} has invalid metadata (expected key-value pairs, found #{klass} instead)")
+      end
+    end
+
+    class InvalidEncoding < Generic
+      def initialize(filename, encoding)
+        super("Could not read #{filename} because the file is not valid #{encoding}.")
+      end
+    end
+
+    class FileUnreadable < Generic
+      def initialize(filename, error)
+        super("Could not read #{filename}: #{error.inspect}")
+      end
+    end
+  end
+end
diff --git a/lib/nanoc/extra/filesystem_tools.rb b/lib/nanoc/data_sources/filesystem/tools.rb
similarity index 89%
rename from lib/nanoc/extra/filesystem_tools.rb
rename to lib/nanoc/data_sources/filesystem/tools.rb
index 66221cc..d9adf37 100644
--- a/lib/nanoc/extra/filesystem_tools.rb
+++ b/lib/nanoc/data_sources/filesystem/tools.rb
@@ -1,8 +1,8 @@
-module Nanoc::Extra
+class Nanoc::DataSources::Filesystem < Nanoc::DataSource
   # Contains useful functions for managing the filesystem.
   #
   # @api private
-  module FilesystemTools
+  module Tools
     # Error that is raised when too many symlink indirections are encountered.
     class MaxSymlinkDepthExceededError < ::Nanoc::Int::Errors::GenericTrivial
       # @return [String] The last filename that was attempted to be
@@ -92,19 +92,24 @@ module Nanoc::Extra
     #
     # @raise [GenericTrivial] when pattern can not be handled
     def all_files_and_dirs_in(dir_name, extra_files)
-      patterns = ["#{dir_name}/**/*"]
-      case extra_files
-      when nil
-      when String
-        patterns << "#{dir_name}/#{extra_files}"
-      when Array
-        patterns.concat(extra_files.map { |extra_file| "#{dir_name}/#{extra_file}" })
-      else
-        raise(
-          Nanoc::Int::Errors::GenericTrivial,
-          "Do not know how to handle extra_files: #{extra_files.inspect}",
-        )
-      end
+      base_patterns = ["#{dir_name}/**/*"]
+
+      extra_patterns =
+        case extra_files
+        when nil
+          []
+        when String
+          ["#{dir_name}/#{extra_files}"]
+        when Array
+          extra_files.map { |extra_file| "#{dir_name}/#{extra_file}" }
+        else
+          raise(
+            Nanoc::Int::Errors::GenericTrivial,
+            "Do not know how to handle extra_files: #{extra_files.inspect}",
+          )
+        end
+
+      patterns = base_patterns + extra_patterns
       Dir.glob(patterns)
     end
     module_function :all_files_and_dirs_in
diff --git a/lib/nanoc/deploying.rb b/lib/nanoc/deploying.rb
new file mode 100644
index 0000000..d2b756b
--- /dev/null
+++ b/lib/nanoc/deploying.rb
@@ -0,0 +1,8 @@
+module Nanoc
+  # @api private
+  module Deploying
+  end
+end
+
+require 'nanoc/deploying/deployer'
+require 'nanoc/deploying/deployers'
diff --git a/lib/nanoc/extra/deployer.rb b/lib/nanoc/deploying/deployer.rb
similarity index 91%
rename from lib/nanoc/extra/deployer.rb
rename to lib/nanoc/deploying/deployer.rb
index 7dd3d85..ff3a9e1 100644
--- a/lib/nanoc/extra/deployer.rb
+++ b/lib/nanoc/deploying/deployer.rb
@@ -1,4 +1,4 @@
-module Nanoc::Extra
+module Nanoc::Deploying
   # Represents a deployer, an object that allows uploading the compiled site
   # to a specific (remote) location.
   #
@@ -37,7 +37,7 @@ module Nanoc::Extra
     #
     # @abstract
     def run
-      raise NotImplementedError.new('Nanoc::Extra::Deployer subclasses must implement #run')
+      raise NotImplementedError.new('Nanoc::Deploying::Deployer subclasses must implement #run')
     end
   end
 end
diff --git a/lib/nanoc/deploying/deployers.rb b/lib/nanoc/deploying/deployers.rb
new file mode 100644
index 0000000..3965363
--- /dev/null
+++ b/lib/nanoc/deploying/deployers.rb
@@ -0,0 +1,10 @@
+module Nanoc::Deploying
+  # @api private
+  module Deployers
+    autoload 'Fog',   'nanoc/deploying/deployers/fog'
+    autoload 'Rsync', 'nanoc/deploying/deployers/rsync'
+
+    Nanoc::Deploying::Deployer.register '::Nanoc::Deploying::Deployers::Fog',   :fog
+    Nanoc::Deploying::Deployer.register '::Nanoc::Deploying::Deployers::Rsync', :rsync
+  end
+end
diff --git a/lib/nanoc/extra/deployers/fog.rb b/lib/nanoc/deploying/deployers/fog.rb
similarity index 97%
rename from lib/nanoc/extra/deployers/fog.rb
rename to lib/nanoc/deploying/deployers/fog.rb
index aef6253..8b97874 100644
--- a/lib/nanoc/extra/deployers/fog.rb
+++ b/lib/nanoc/deploying/deployers/fog.rb
@@ -1,4 +1,4 @@
-module Nanoc::Extra::Deployers
+module Nanoc::Deploying::Deployers
   # A deployer that deploys a site using [fog](https://github.com/geemus/fog).
   #
   # @example A deployment configuration with public and staging configurations
@@ -20,7 +20,7 @@ module Nanoc::Extra::Deployers
   #       bucket:     nanoc-site-staging
   #
   # @api private
-  class Fog < ::Nanoc::Extra::Deployer
+  class Fog < ::Nanoc::Deploying::Deployer
     class FogWrapper
       def initialize(directory, is_dry_run)
         @directory = directory
@@ -75,7 +75,7 @@ module Nanoc::Extra::Deployers
       end
     end
 
-    # @see Nanoc::Extra::Deployer#run
+    # @see Nanoc::Deploying::Deployer#run
     def run
       require 'fog'
 
diff --git a/lib/nanoc/extra/deployers/rsync.rb b/lib/nanoc/deploying/deployers/rsync.rb
similarity index 93%
rename from lib/nanoc/extra/deployers/rsync.rb
rename to lib/nanoc/deploying/deployers/rsync.rb
index 3016807..b7c037a 100644
--- a/lib/nanoc/extra/deployers/rsync.rb
+++ b/lib/nanoc/deploying/deployers/rsync.rb
@@ -1,4 +1,4 @@
-module Nanoc::Extra::Deployers
+module Nanoc::Deploying::Deployers
   # A deployer that deploys a site using rsync.
   #
   # The configuration has should include a `:dst` value, a string containing
@@ -18,7 +18,7 @@ module Nanoc::Extra::Deployers
   #       options: [ "-glpPrtvz" ]
   #
   # @api private
-  class Rsync < ::Nanoc::Extra::Deployer
+  class Rsync < ::Nanoc::Deploying::Deployer
     # Default rsync options
     DEFAULT_OPTIONS = [
       '--group',
@@ -35,7 +35,7 @@ module Nanoc::Extra::Deployers
       '--exclude=".git"',
     ].freeze
 
-    # @see Nanoc::Extra::Deployer#run
+    # @see Nanoc::Deploying::Deployer#run
     def run
       # Get params
       src = source_path + '/'
diff --git a/lib/nanoc/extra.rb b/lib/nanoc/extra.rb
index b2cd153..1b98718 100644
--- a/lib/nanoc/extra.rb
+++ b/lib/nanoc/extra.rb
@@ -1,13 +1,21 @@
+require 'nanoc/checking'
+require 'nanoc/deploying'
+
 # @api private
 module Nanoc::Extra
-  autoload 'Checking',            'nanoc/extra/checking'
-  autoload 'FilesystemTools',     'nanoc/extra/filesystem_tools'
   autoload 'LinkCollector',       'nanoc/extra/link_collector.rb'
-  autoload 'Pruner',              'nanoc/extra/pruner'
   autoload 'Piper',               'nanoc/extra/piper'
   autoload 'JRubyNokogiriWarner', 'nanoc/extra/jruby_nokogiri_warner'
+
+  # @deprecated
+  Checking = Nanoc::Checking
+
+  # @deprecated
+  Deployer = Nanoc::Deploying::Deployer
+
+  # @deprecated
+  Pruner = Nanoc::Pruner
 end
 
 require 'nanoc/extra/core_ext'
-require 'nanoc/extra/deployer'
-require 'nanoc/extra/deployers'
+require 'nanoc/extra/parallel_collection'
diff --git a/lib/nanoc/extra/checking.rb b/lib/nanoc/extra/checking.rb
deleted file mode 100644
index 1928031..0000000
--- a/lib/nanoc/extra/checking.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-module Nanoc::Extra
-  # @api private
-  module Checking
-    autoload 'Check',  'nanoc/extra/checking/check'
-    autoload 'DSL',    'nanoc/extra/checking/dsl'
-    autoload 'Runner', 'nanoc/extra/checking/runner.rb'
-    autoload 'Issue',  'nanoc/extra/checking/issue'
-  end
-end
-
-require 'nanoc/extra/checking/checks'
diff --git a/lib/nanoc/extra/checking/checks.rb b/lib/nanoc/extra/checking/checks.rb
deleted file mode 100644
index 7821b72..0000000
--- a/lib/nanoc/extra/checking/checks.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-require_relative 'checks/w3c_validator'
-
-# @api private
-module Nanoc::Extra::Checking::Checks
-  autoload 'CSS',           'nanoc/extra/checking/checks/css'
-  autoload 'ExternalLinks', 'nanoc/extra/checking/checks/external_links'
-  autoload 'HTML',          'nanoc/extra/checking/checks/html'
-  autoload 'InternalLinks', 'nanoc/extra/checking/checks/internal_links'
-  autoload 'Stale',         'nanoc/extra/checking/checks/stale'
-  autoload 'MixedContent',  'nanoc/extra/checking/checks/mixed_content'
-
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::CSS',           :css
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::ExternalLinks', :external_links
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::ExternalLinks', :elinks
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::HTML',          :html
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::InternalLinks', :internal_links
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::InternalLinks', :ilinks
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::Stale',         :stale
-  Nanoc::Extra::Checking::Check.register '::Nanoc::Extra::Checking::Checks::MixedContent',  :mixed_content
-end
diff --git a/lib/nanoc/extra/core_ext/time.rb b/lib/nanoc/extra/core_ext/time.rb
index 6973c7a..b37e2cf 100644
--- a/lib/nanoc/extra/core_ext/time.rb
+++ b/lib/nanoc/extra/core_ext/time.rb
@@ -2,7 +2,7 @@
 module Nanoc::Extra::TimeExtensions
   # @return [String] The time in an ISO-8601 date format.
   def __nanoc_to_iso8601_date
-    strftime('%Y-%m-%d')
+    getutc.strftime('%Y-%m-%d')
   end
 
   # @return [String] The time in an ISO-8601 time format.
diff --git a/lib/nanoc/extra/deployers.rb b/lib/nanoc/extra/deployers.rb
deleted file mode 100644
index 56c2309..0000000
--- a/lib/nanoc/extra/deployers.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-module Nanoc::Extra
-  # @api private
-  module Deployers
-    autoload 'Fog',   'nanoc/extra/deployers/fog'
-    autoload 'Rsync', 'nanoc/extra/deployers/rsync'
-
-    Nanoc::Extra::Deployer.register '::Nanoc::Extra::Deployers::Fog',   :fog
-    Nanoc::Extra::Deployer.register '::Nanoc::Extra::Deployers::Rsync', :rsync
-  end
-end
diff --git a/lib/nanoc/extra/parallel_collection.rb b/lib/nanoc/extra/parallel_collection.rb
new file mode 100644
index 0000000..43c856d
--- /dev/null
+++ b/lib/nanoc/extra/parallel_collection.rb
@@ -0,0 +1,57 @@
+require 'thread'
+
+module Nanoc::Extra
+  # @api private
+  class ParallelCollection
+    STOP = Object.new
+
+    include Nanoc::Int::ContractsSupport
+
+    contract C::RespondTo[:each], C::KeywordArgs[parallelism: Fixnum] => C::Any
+    def initialize(enum, parallelism: 2)
+      @enum = enum
+      @parallelism = parallelism
+    end
+
+    contract C::Func[C::Any => C::Any] => self
+    def each
+      queue = SizedQueue.new(2 * @parallelism)
+      error = nil
+
+      threads = (1.. at parallelism).map do
+        Thread.new do
+          loop do
+            begin
+              elem = queue.pop
+              break if error
+              break if STOP.equal?(elem)
+              yield elem
+            rescue => err
+              error = err
+              break
+            end
+          end
+        end
+      end
+
+      @enum.each { |e| queue << e }
+      @parallelism.times { queue << STOP }
+
+      threads.each(&:join)
+
+      raise error if error
+      self
+    end
+
+    contract C::Func[C::Any => C::Any] => C::RespondTo[:each]
+    def map
+      [].tap do |all|
+        mutex = Mutex.new
+        each do |e|
+          res = yield(e)
+          mutex.synchronize { all << res }
+        end
+      end
+    end
+  end
+end
diff --git a/lib/nanoc/extra/pruner.rb b/lib/nanoc/extra/pruner.rb
deleted file mode 100644
index a9059a5..0000000
--- a/lib/nanoc/extra/pruner.rb
+++ /dev/null
@@ -1,87 +0,0 @@
-module Nanoc::Extra
-  # Responsible for finding and deleting files in the site’s output directory
-  # that are not managed by Nanoc.
-  #
-  # @api private
-  class Pruner
-    # @return [Nanoc::Int::Site] The site this pruner belongs to
-    attr_reader :site
-
-    # @param [Nanoc::Int::Site] site The site for which a pruner is created
-    #
-    # @param [Boolean] dry_run true if the files to be deleted
-    #   should only be printed instead of actually deleted, false if the files
-    #   should actually be deleted.
-    #
-    # @param [Enumerable<String>] exclude
-    def initialize(site, dry_run: false, exclude: [])
-      @site    = site
-      @dry_run = dry_run
-      @exclude = exclude
-    end
-
-    # Prunes all output files not managed by Nanoc.
-    #
-    # @return [void]
-    def run
-      require 'find'
-
-      return unless File.directory?(site.config[:output_dir])
-
-      # Get compiled files
-      # FIXME: requires #build_reps to have been called
-      all_raw_paths = site.compiler.reps.flat_map { |r| r.raw_paths.values }
-      compiled_files = all_raw_paths.flatten.compact.select { |f| File.file?(f) }
-
-      # Get present files and dirs
-      present_files = []
-      present_dirs = []
-      Find.find(site.config[:output_dir] + '/') do |f|
-        present_files << f if File.file?(f)
-        present_dirs << f if File.directory?(f)
-      end
-
-      # Remove stray files
-      stray_files = (present_files - compiled_files)
-      stray_files.each do |f|
-        next if filename_excluded?(f)
-        delete_file(f)
-      end
-
-      # Remove empty directories
-      present_dirs.reverse_each do |dir|
-        next if Dir.foreach(dir) { |n| break true if n !~ /\A\.\.?\z/ }
-        next if filename_excluded?(dir)
-        delete_dir(dir)
-      end
-    end
-
-    # @param [String] filename The filename to check
-    #
-    # @return [Boolean] true if the given file is excluded, false otherwise
-    def filename_excluded?(filename)
-      pathname = Pathname.new(filename)
-      @exclude.any? { |e| pathname.__nanoc_include_component?(e) }
-    end
-
-    protected
-
-    def delete_file(file)
-      if @dry_run
-        puts file
-      else
-        Nanoc::CLI::Logger.instance.file(:high, :delete, file)
-        FileUtils.rm(file)
-      end
-    end
-
-    def delete_dir(dir)
-      if @dry_run
-        puts dir
-      else
-        Nanoc::CLI::Logger.instance.file(:high, :delete, dir)
-        Dir.rmdir(dir)
-      end
-    end
-  end
-end
diff --git a/lib/nanoc/filters/asciidoc.rb b/lib/nanoc/filters/asciidoc.rb
index ba5908d..de983fc 100644
--- a/lib/nanoc/filters/asciidoc.rb
+++ b/lib/nanoc/filters/asciidoc.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.2.0
-  #
   # @api private
   class AsciiDoc < Nanoc::Filter
     # Runs the content through [AsciiDoc](http://www.methods.co.nz/asciidoc/).
diff --git a/lib/nanoc/filters/coffeescript.rb b/lib/nanoc/filters/coffeescript.rb
index 687bdcc..cb0356a 100644
--- a/lib/nanoc/filters/coffeescript.rb
+++ b/lib/nanoc/filters/coffeescript.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.3.0
-  #
   # @api private
   class CoffeeScript < Nanoc::Filter
     requires 'coffee-script'
diff --git a/lib/nanoc/filters/colorize_syntax.rb b/lib/nanoc/filters/colorize_syntax.rb
index 566b4d2..d5ca8fa 100644
--- a/lib/nanoc/filters/colorize_syntax.rb
+++ b/lib/nanoc/filters/colorize_syntax.rb
@@ -250,8 +250,6 @@ module Nanoc::Filters
 
     # Runs the content through [Highlight](http://www.andre-simon.de/doku/highlight/en/highlight.html).
     #
-    # @since 3.2.0
-    #
     # @param [String] code The code to colorize
     #
     # @param [String] language The language the code is written in
@@ -312,10 +310,21 @@ module Nanoc::Filters
     def rouge(code, language, params = {})
       require 'rouge'
 
-      formatter_options = {
-        css_class: params.fetch(:css_class, 'highlight'),
-      }
-      formatter = Rouge::Formatters::HTML.new(formatter_options)
+      if Rouge.version < '2' || params.fetch(:legacy, false)
+        # Rouge 1.x or Rouge 2.x legacy options
+        formatter_options = {
+          css_class: params.fetch(:css_class, 'highlight'),
+          inline_theme: params.fetch(:inline_theme, nil),
+          line_numbers: params.fetch(:line_numbers, false),
+          start_line: params.fetch(:start_line, 1),
+          wrap: params.fetch(:wrap, false),
+        }
+        formatter_cls = Rouge::Formatters.const_get(Rouge.version < '2' ? 'HTML' : 'HTMLLegacy')
+        formatter = formatter_cls.new(formatter_options)
+      else
+        formatter = params.fetch(:formatter, Rouge::Formatters::HTML.new)
+      end
+
       lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
       formatter.format(lexer.lex(code))
     end
diff --git a/lib/nanoc/filters/handlebars.rb b/lib/nanoc/filters/handlebars.rb
index 5768491..f6be6ab 100644
--- a/lib/nanoc/filters/handlebars.rb
+++ b/lib/nanoc/filters/handlebars.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.4.0
-  #
   # @api private
   class Handlebars < Nanoc::Filter
     requires 'handlebars'
diff --git a/lib/nanoc/filters/kramdown.rb b/lib/nanoc/filters/kramdown.rb
index caa63c7..eb8c49a 100644
--- a/lib/nanoc/filters/kramdown.rb
+++ b/lib/nanoc/filters/kramdown.rb
@@ -10,10 +10,22 @@ module Nanoc::Filters
     #
     # @return [String] The filtered content
     def run(content, params = {})
+      params = params.dup
+      warning_filters = params.delete(:warning_filters)
       document = ::Kramdown::Document.new(content, params)
 
-      document.warnings.each do |warning|
-        $stderr.puts "kramdown warning: #{warning}"
+      if warning_filters
+        r = Regexp.union(warning_filters)
+        warnings = document.warnings.reject { |warning| r =~ warning }
+      else
+        warnings = document.warnings
+      end
+
+      if warnings.any?
+        $stderr.puts "kramdown warning(s) for #{@item_rep.inspect}"
+        warnings.each do |warning|
+          $stderr.puts "  #{warning}"
+        end
       end
 
       document.to_html
diff --git a/lib/nanoc/filters/less.rb b/lib/nanoc/filters/less.rb
index b7bd874..5479c21 100644
--- a/lib/nanoc/filters/less.rb
+++ b/lib/nanoc/filters/less.rb
@@ -10,41 +10,65 @@ module Nanoc::Filters
     #
     # @return [String] The filtered content
     def run(content, params = {})
-      # Find imports (hacky)
+      # Create dependencies
+      imported_filenames = imported_filenames_from(content)
+      imported_items = imported_filenames_to_items(imported_filenames)
+      depend_on(imported_items)
+
+      # Add filename to load path
+      paths = [File.dirname(@item[:content_filename])]
+      on_main_fiber do
+        parser = ::Less::Parser.new(paths: paths)
+        parser.parse(content).to_css(params)
+      end
+    end
+
+    def imported_filenames_from(content)
       imports = []
       imports.concat(content.scan(/^@import\s+(["'])([^\1]+?)\1;/))
       imports.concat(content.scan(/^@import\s+url\((["']?)([^)]+?)\1\);/))
-      imported_filenames = imports.map do |i|
-        i[1] =~ /\.(less|css)$/ ? i[1] : i[1] + '.less'
-      end
 
-      # Convert to items
-      imported_items = imported_filenames.map do |filename|
-        # Find directory for this item
-        current_dir_pathname = Pathname.new(@item[:content_filename]).dirname.realpath
+      imports.map { |i| i[1] =~ /\.(less|css)$/ ? i[1] : i[1] + '.less' }
+    end
 
-        # Find absolute pathname for imported item
-        imported_pathname    = Pathname.new(filename)
-        if imported_pathname.relative?
-          imported_pathname = current_dir_pathname + imported_pathname
-        end
-        next unless imported_pathname.exist?
-        imported_filename = imported_pathname.realpath
+    def imported_filenames_to_items(imported_filenames)
+      item_dir_path = Pathname.new(@item[:content_filename]).dirname.realpath
+      cwd = Pathname.pwd # FIXME: ugly (get site dir instead)
+
+      imported_filenames.map do |filename|
+        full_paths = Set.new
+
+        imported_pathname = Pathname.new(filename)
+        full_paths << find_file(imported_pathname, item_dir_path)
+        full_paths << find_file(imported_pathname, cwd)
 
         # Find matching item
         @items.find do |i|
           next if i[:content_filename].nil?
-          Pathname.new(i[:content_filename]).realpath == imported_filename
+          item_path = Pathname.new(i[:content_filename]).realpath
+          full_paths.any? { |fp| fp == item_path }
         end
       end.compact
+    end
 
-      # Create dependencies
-      depend_on(imported_items)
+    # @param [Pathname] pathname Pathname of the file to find. Can be relative or absolute.
+    #
+    # @param [Pathname] root_pathname Directory pathname from which the search will start.
+    #
+    # @return [String, nil] A string containing the full path if a file is found, otherwise nil.
+    def find_file(pathname, root_pathname)
+      absolute_pathname =
+        if pathname.relative?
+          root_pathname + pathname
+        else
+          pathname
+        end
 
-      # Add filename to load path
-      paths = [File.dirname(@item[:content_filename])]
-      parser = ::Less::Parser.new(paths: paths)
-      parser.parse(content).to_css params
+      if absolute_pathname.exist?
+        absolute_pathname.realpath
+      else
+        nil
+      end
     end
   end
 end
diff --git a/lib/nanoc/filters/mustache.rb b/lib/nanoc/filters/mustache.rb
index 597929c..e6cf28d 100644
--- a/lib/nanoc/filters/mustache.rb
+++ b/lib/nanoc/filters/mustache.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.2.0
-  #
   # @api private
   class Mustache < Nanoc::Filter
     requires 'mustache'
@@ -13,7 +11,7 @@ module Nanoc::Filters
     #
     # @return [String] The filtered content
     def run(content, _params = {})
-      context = item.attributes.merge({ yield: assigns[:content] })
+      context = item.attributes.merge(yield: assigns[:content])
       ::Mustache.render(content, context)
     end
   end
diff --git a/lib/nanoc/filters/rdoc.rb b/lib/nanoc/filters/rdoc.rb
index bb65b3e..47012b4 100644
--- a/lib/nanoc/filters/rdoc.rb
+++ b/lib/nanoc/filters/rdoc.rb
@@ -3,11 +3,6 @@ module Nanoc::Filters
   class RDoc < Nanoc::Filter
     requires 'rdoc'
 
-    def self.setup
-      gem 'rdoc', '~> 4.0'
-      super
-    end
-
     # Runs the content through [RDoc::Markup](http://docs.seattlerb.org/rdoc/RDoc/Markup.html).
     # This method takes no options.
     #
diff --git a/lib/nanoc/filters/redcarpet.rb b/lib/nanoc/filters/redcarpet.rb
index 0295c7c..6cacd75 100644
--- a/lib/nanoc/filters/redcarpet.rb
+++ b/lib/nanoc/filters/redcarpet.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.2.0
-  #
   # @api private
   class Redcarpet < Nanoc::Filter
     requires 'redcarpet'
@@ -23,8 +21,6 @@ module Nanoc::Filters
     #
     #   For Redcarpet 2.x
     #
-    #   @since 3.2.4
-    #
     #   @param [String] content The content to filter
     #
     #   @option params [Hash] :options ({}) A list of options to pass on to
diff --git a/lib/nanoc/filters/relativize_paths.rb b/lib/nanoc/filters/relativize_paths.rb
index 8e296f3..d5bdd12 100644
--- a/lib/nanoc/filters/relativize_paths.rb
+++ b/lib/nanoc/filters/relativize_paths.rb
@@ -4,7 +4,7 @@ module Nanoc::Filters
     require 'nanoc/helpers/link_to'
     include Nanoc::Helpers::LinkTo
 
-    SELECTORS = ['*/@href', '*/@src', 'object/@data', 'param[@name="movie"]/@content', 'comment()'].freeze
+    SELECTORS = ['*/@href', '*/@src', 'object/@data', 'param[@name="movie"]/@content', 'form/@action', 'comment()'].freeze
 
     # Relativizes all paths in the given content, which can be HTML, XHTML, XML
     # or CSS. This filter is quite useful if a site needs to be hosted in a
diff --git a/lib/nanoc/filters/slim.rb b/lib/nanoc/filters/slim.rb
index 7040fef..654350e 100644
--- a/lib/nanoc/filters/slim.rb
+++ b/lib/nanoc/filters/slim.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.2.0
-  #
   # @api private
   class Slim < Nanoc::Filter
     requires 'slim'
diff --git a/lib/nanoc/filters/typogruby.rb b/lib/nanoc/filters/typogruby.rb
index 30e6b05..0e5026f 100644
--- a/lib/nanoc/filters/typogruby.rb
+++ b/lib/nanoc/filters/typogruby.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.2.0
-  #
   # @api private
   class Typogruby < Nanoc::Filter
     requires 'typogruby'
diff --git a/lib/nanoc/filters/xsl.rb b/lib/nanoc/filters/xsl.rb
index a3dbc25..af863be 100644
--- a/lib/nanoc/filters/xsl.rb
+++ b/lib/nanoc/filters/xsl.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.3.0
-  #
   # @api private
   class XSL < Nanoc::Filter
     requires 'nokogiri'
diff --git a/lib/nanoc/filters/yui_compressor.rb b/lib/nanoc/filters/yui_compressor.rb
index dc572aa..b589087 100644
--- a/lib/nanoc/filters/yui_compressor.rb
+++ b/lib/nanoc/filters/yui_compressor.rb
@@ -1,6 +1,4 @@
 module Nanoc::Filters
-  # @since 3.3.0
-  #
   # @api private
   class YUICompressor < Nanoc::Filter
     requires 'yuicompressor'
diff --git a/lib/nanoc/helpers/blogging.rb b/lib/nanoc/helpers/blogging.rb
index 867d65c..a19aa79 100644
--- a/lib/nanoc/helpers/blogging.rb
+++ b/lib/nanoc/helpers/blogging.rb
@@ -72,6 +72,10 @@ module Nanoc::Helpers
         sorted_relevant_articles.first
       end
 
+      def updated
+        relevant_articles.map { |a| attribute_to_time(a[:updated_at] || a[:created_at]) }.max
+      end
+
       def validate_config
         if @config[:base_url].nil?
           raise Nanoc::Int::Errors::GenericTrivial.new('Cannot build Atom feed: site configuration has no base_url')
@@ -109,7 +113,7 @@ module Nanoc::Helpers
           xml.title title
 
           # Add date
-          xml.updated(attribute_to_time(last_article[:created_at]).__nanoc_to_iso8601_time)
+          xml.updated(updated.__nanoc_to_iso8601_time)
 
           # Add links
           xml.link(rel: 'alternate', href: root_url)
@@ -244,7 +248,7 @@ module Nanoc::Helpers
       when DateTime
         arg.to_time
       when Date
-        Time.local(arg.year, arg.month, arg.day)
+        Time.utc(arg.year, arg.month, arg.day)
       when String
         Time.parse(arg)
       else
diff --git a/lib/nanoc/helpers/breadcrumbs.rb b/lib/nanoc/helpers/breadcrumbs.rb
index c30eb0a..26908e3 100644
--- a/lib/nanoc/helpers/breadcrumbs.rb
+++ b/lib/nanoc/helpers/breadcrumbs.rb
@@ -1,31 +1,25 @@
 module Nanoc::Helpers
   # @see http://nanoc.ws/doc/reference/helpers/#breadcrumbs
   module Breadcrumbs
-    class CannotGetBreadcrumbsForNonLegacyItem < Nanoc::Int::Errors::Generic
-      def initialize(identifier)
-        super("You cannot build a breadcrumbs trail for an item that has a “full” identifier (#{identifier}). Doing so is only possible for items that have a legacy identifier.")
-      end
-    end
-
     # @return [Array]
     def breadcrumbs_trail
-      unless @item.identifier.legacy?
-        raise CannotGetBreadcrumbsForNonLegacyItem.new(@item.identifier)
-      end
+      # e.g. ['', '/foo', '/foo/bar']
+      components = item.identifier.components
+      prefixes = components.inject(['']) { |acc, elem| acc + [acc.last + '/' + elem] }
 
-      trail      = []
-      idx_start  = 0
-
-      loop do
-        idx = @item.identifier.to_s.index('/', idx_start)
-        break if idx.nil?
-
-        idx_start = idx + 1
-        identifier = @item.identifier.to_s[0..idx]
-        trail << @items[identifier]
+      if @item.identifier.legacy?
+        prefixes.map { |pr| @items[Nanoc::Identifier.new('/' + pr, type: :legacy)] }
+      else
+        prefixes
+          .reject { |pr| pr =~ /^\/index\./ }
+          .map do |pr|
+            if pr == ''
+              @items['/index.*']
+            else
+              @items[Nanoc::Identifier.new(pr).without_ext + '.*']
+            end
+          end
       end
-
-      trail
     end
   end
 end
diff --git a/lib/nanoc/helpers/capturing.rb b/lib/nanoc/helpers/capturing.rb
index 3a1df2b..cc0ded7 100644
--- a/lib/nanoc/helpers/capturing.rb
+++ b/lib/nanoc/helpers/capturing.rb
@@ -27,30 +27,32 @@ module Nanoc::Helpers
         existing_behavior = params.fetch(:existing, :error)
 
         # Capture
-        content = capture(&block)
+        content_string = capture(&block)
 
-        # Prepare for store
+        # Get existing contents and prep for store
         snapshot_contents = @item.reps[:default].unwrap.snapshot_contents
         capture_name = "__capture_#{name}".to_sym
-        case existing_behavior
-        when :overwrite
-          snapshot_contents[capture_name] = ''
-        when :append
-          snapshot_contents[capture_name] ||= ''
-        when :error
-          if snapshot_contents[capture_name] && snapshot_contents[capture_name] != content
-            # FIXME: get proper exception
-            raise "a capture named #{name.inspect} for #{@item.identifier} already exists"
+        old_content_string =
+          case existing_behavior
+          when :overwrite
+            ''
+          when :append
+            c = snapshot_contents[capture_name]
+            c ? c.string : ''
+          when :error
+            if snapshot_contents[capture_name] && snapshot_contents[capture_name].string != content_string
+              # FIXME: get proper exception
+              raise "a capture named #{name.inspect} for #{@item.identifier} already exists"
+            else
+              ''
+            end
           else
-            snapshot_contents[capture_name] = ''
+            raise ArgumentError, 'expected :existing_behavior param to #content_for to be one of ' \
+              ":overwrite, :append, or :error, but #{existing_behavior.inspect} was given"
           end
-        else
-          raise ArgumentError, 'expected :existing_behavior param to #content_for to be one of ' \
-            ":overwrite, :append, or :error, but #{existing_behavior.inspect} was given"
-        end
 
         # Store
-        snapshot_contents[capture_name] << content
+        snapshot_contents[capture_name] = Nanoc::Int::TextualContent.new(old_content_string + content_string)
       else # Get content
         if args.size != 2
           raise ArgumentError, 'expected 2 arguments (the item ' \
@@ -64,14 +66,16 @@ module Nanoc::Helpers
         # Create dependency
         if @item.nil? || item != @item.unwrap
           dependency_tracker = @config._context.dependency_tracker
-          dependency_tracker.bounce(item.unwrap)
+          dependency_tracker.bounce(item.unwrap, compiled_content: true)
 
           unless rep.compiled?
-            raise Nanoc::Int::Errors::UnmetDependency.new(rep)
+            Fiber.yield(Nanoc::Int::Errors::UnmetDependency.new(rep))
+            return content_for(*args, &block)
           end
         end
 
-        rep.snapshot_contents["__capture_#{name}".to_sym]
+        content = rep.snapshot_contents["__capture_#{name}".to_sym]
+        content ? content.string : nil
       end
     end
 
@@ -92,7 +96,7 @@ module Nanoc::Helpers
 
       # Depending on how the filter outputs, the result might be a
       # single string or an array of strings (slim outputs the latter).
-      erbout_addition = erbout_addition.join if erbout_addition.is_a? Array
+      erbout_addition = erbout_addition.join('') if erbout_addition.is_a? Array
 
       # Done.
       erbout_addition
diff --git a/lib/nanoc/helpers/link_to.rb b/lib/nanoc/helpers/link_to.rb
index cfe2027..c7aad56 100644
--- a/lib/nanoc/helpers/link_to.rb
+++ b/lib/nanoc/helpers/link_to.rb
@@ -78,15 +78,11 @@ module Nanoc::Helpers
 
       # Calculate the relative path (method depends on whether destination is
       # a directory or not).
-      relative_path =
-        if src_path.to_s[-1, 1] != '/'
-          dst_path.relative_path_from(src_path.dirname).to_s
-        else
-          dst_path.relative_path_from(src_path).to_s
-        end
+      from = src_path.to_s.end_with?('/') ? src_path : src_path.dirname
+      relative_path = dst_path.relative_path_from(from).to_s
 
       # Add trailing slash if necessary
-      if dst_path.to_s[-1, 1] == '/'
+      if dst_path.to_s.end_with?('/')
         relative_path << '/'
       end
 
diff --git a/lib/nanoc/helpers/rendering.rb b/lib/nanoc/helpers/rendering.rb
index 49822f7..87b385c 100644
--- a/lib/nanoc/helpers/rendering.rb
+++ b/lib/nanoc/helpers/rendering.rb
@@ -20,7 +20,7 @@ module Nanoc::Helpers
 
       # Visit
       dependency_tracker = @config._context.dependency_tracker
-      dependency_tracker.bounce(layout)
+      dependency_tracker.bounce(layout, raw_content: true)
 
       # Capture content, if any
       captured_content = block_given? ? capture(&block) : nil
@@ -37,7 +37,7 @@ module Nanoc::Helpers
       }.merge(other_assigns)
 
       # Get filter name
-      filter_name, filter_args = *@config._context.compiler.filter_name_and_args_for_layout(layout)
+      filter_name, filter_args = *@config._context.compilation_context.filter_name_and_args_for_layout(layout)
       raise Nanoc::Int::Errors::CannotDetermineFilter.new(layout.identifier) if filter_name.nil?
 
       # Get filter class
@@ -47,28 +47,20 @@ module Nanoc::Helpers
       # Create filter
       filter = filter_class.new(assigns)
 
-      begin
-        # Notify start
-        Nanoc::Int::NotificationCenter.post(:processing_started, layout)
+      # Layout
+      content = layout.content
+      arg = content.binary? ? content.filename : content.string
+      result = filter.setup_and_run(arg, filter_args)
 
-        # Layout
-        content = layout.content
-        arg = content.binary? ? content.filename : content.string
-        result = filter.setup_and_run(arg, filter_args)
-
-        # Append to erbout if we have a block
-        if block_given?
-          # Append result and return nothing
-          erbout = eval('_erbout', block.binding)
-          erbout << result
-          ''
-        else
-          # Return result
-          result
-        end
-      ensure
-        # Notify end
-        Nanoc::Int::NotificationCenter.post(:processing_ended, layout)
+      # Append to erbout if we have a block
+      if block_given?
+        # Append result and return nothing
+        erbout = eval('_erbout', block.binding)
+        erbout << result
+        ''
+      else
+        # Return result
+        result
       end
     end
   end
diff --git a/lib/nanoc/rule_dsl/action_provider.rb b/lib/nanoc/rule_dsl/action_provider.rb
index f8a063a..e14294d 100644
--- a/lib/nanoc/rule_dsl/action_provider.rb
+++ b/lib/nanoc/rule_dsl/action_provider.rb
@@ -55,7 +55,7 @@ module Nanoc::RuleDSL
           reps: reps,
           items: site.items,
           dependency_tracker: dependency_tracker,
-          compiler: site.compiler,
+          compilation_context: site.compiler.compilation_context,
         )
       ctx = new_postprocessor_context(site, view_context)
 
@@ -72,7 +72,7 @@ module Nanoc::RuleDSL
           reps: nil,
           items: nil,
           dependency_tracker: dependency_tracker,
-          compiler: nil,
+          compilation_context: nil,
         )
 
       Nanoc::Int::Context.new(
diff --git a/lib/nanoc/rule_dsl/compiler_dsl.rb b/lib/nanoc/rule_dsl/compiler_dsl.rb
index 1efa6cd..0831184 100644
--- a/lib/nanoc/rule_dsl/compiler_dsl.rb
+++ b/lib/nanoc/rule_dsl/compiler_dsl.rb
@@ -159,8 +159,6 @@ module Nanoc::RuleDSL
     #
     # @return [void]
     #
-    # @since 3.2.0
-    #
     # @example Copying the `/foo/` item as-is
     #
     #     passthrough '/foo/'
@@ -278,9 +276,15 @@ module Nanoc::RuleDSL
         # Add leading/trailing slashes if necessary
         new_identifier = identifier.dup
         new_identifier[/^/] = '/' if identifier[0, 1] != '/'
-        new_identifier[/$/] = '/' unless ['*', '/'].include?(identifier[-1, 1])
+        new_identifier[/$/] = '/?' unless ['*', '/'].include?(identifier[-1, 1])
+
+        regex_string =
+          new_identifier
+          .gsub('.', '\.')
+          .gsub('*', '(.*?)')
+          .gsub('+', '(.+?)')
 
-        /^#{new_identifier.gsub('*', '(.*?)').gsub('+', '(.+?)')}$/
+        /^#{regex_string}$/
       else
         identifier
       end
diff --git a/lib/nanoc/rule_dsl/recording_executor.rb b/lib/nanoc/rule_dsl/recording_executor.rb
index 3e56670..d3b2d1d 100644
--- a/lib/nanoc/rule_dsl/recording_executor.rb
+++ b/lib/nanoc/rule_dsl/recording_executor.rb
@@ -1,55 +1,33 @@
 module Nanoc
   module RuleDSL
     class RecordingExecutor
-      class PathWithoutInitialSlashError < ::Nanoc::Error
-        def initialize(rep, basic_path)
-          super("The path returned for the #{rep.inspect} item representation, “#{basic_path}”, does not start with a slash. Please ensure that all routing rules return a path that starts with a slash.")
-        end
-      end
-
-      attr_reader :rule_memory
-
-      def initialize(item_rep, rules_collection, site)
-        @item_rep = item_rep
-        @rules_collection = rules_collection
-        @site = site
+      include Nanoc::Int::ContractsSupport
 
-        @rule_memory = Nanoc::Int::RuleMemory.new(item_rep)
+      def initialize(rule_memory)
+        @rule_memory = rule_memory
       end
 
-      def filter(_rep, filter_name, filter_args = {})
+      def filter(filter_name, filter_args = {})
         @rule_memory.add_filter(filter_name, filter_args)
       end
 
-      def layout(_rep, layout_identifier, extra_filter_args = {})
+      def layout(layout_identifier, extra_filter_args = {})
         unless layout_identifier.is_a?(String)
           raise ArgumentError.new('The layout passed to #layout must be a string')
         end
 
         unless @rule_memory.any_layouts?
-          @rule_memory.add_snapshot(:pre, true, nil)
+          @rule_memory.add_snapshot(:pre, nil)
         end
 
         @rule_memory.add_layout(layout_identifier, extra_filter_args)
       end
 
-      def snapshot(rep, snapshot_name, final: true, path: nil)
-        actual_path = final ? (path || basic_path_from_rules_for(rep, snapshot_name)) : nil
-        @rule_memory.add_snapshot(snapshot_name, final, actual_path)
-      end
-
-      def basic_path_from_rules_for(rep, snapshot_name)
-        routing_rules = @rules_collection.routing_rules_for(rep)
-        routing_rule = routing_rules[snapshot_name]
-        return nil if routing_rule.nil?
-
-        dependency_tracker = Nanoc::Int::DependencyTracker::Null.new
-        view_context = Nanoc::ViewContext.new(reps: nil, items: nil, dependency_tracker: dependency_tracker, compiler: nil)
-        basic_path = routing_rule.apply_to(rep, executor: nil, site: @site, view_context: view_context)
-        if basic_path && !basic_path.start_with?('/')
-          raise PathWithoutInitialSlashError.new(rep, basic_path)
-        end
-        basic_path
+      Pathlike = C::Maybe[C::Or[String, Nanoc::Identifier]]
+      contract Symbol, C::KeywordArgs[path: C::Optional[Pathlike]] => nil
+      def snapshot(snapshot_name, path: nil)
+        @rule_memory.add_snapshot(snapshot_name, path && path.to_s)
+        nil
       end
     end
   end
diff --git a/lib/nanoc/rule_dsl/rule.rb b/lib/nanoc/rule_dsl/rule.rb
index 301c50b..ee11bf4 100644
--- a/lib/nanoc/rule_dsl/rule.rb
+++ b/lib/nanoc/rule_dsl/rule.rb
@@ -9,8 +9,6 @@ module Nanoc::RuleDSL
 
     # @return [Symbol] The name of the snapshot this rule will apply to.
     #   Ignored for compilation rules, but used for routing rules.
-    #
-    # @since 3.2.0
     attr_reader :snapshot_name
 
     attr_reader :pattern
diff --git a/lib/nanoc/rule_dsl/rule_context.rb b/lib/nanoc/rule_dsl/rule_context.rb
index 6753f95..58dbf5b 100644
--- a/lib/nanoc/rule_dsl/rule_context.rb
+++ b/lib/nanoc/rule_dsl/rule_context.rb
@@ -35,7 +35,7 @@ module Nanoc::RuleDSL
     #
     # @return [void]
     def filter(filter_name, filter_args = {})
-      @_executor.filter(rep.unwrap, filter_name, filter_args)
+      @_executor.filter(filter_name, filter_args)
     end
 
     # Layouts the current representation (calls {Nanoc::Int::ItemRep#layout} with
@@ -48,7 +48,7 @@ module Nanoc::RuleDSL
     #
     # @return [void]
     def layout(layout_identifier, extra_filter_args = nil)
-      @_executor.layout(rep.unwrap, layout_identifier, extra_filter_args)
+      @_executor.layout(layout_identifier, extra_filter_args)
     end
 
     # Creates a snapshot of the current compiled item content. Calls
@@ -62,7 +62,7 @@ module Nanoc::RuleDSL
     #
     # @return [void]
     def snapshot(snapshot_name, path: nil)
-      @_executor.snapshot(rep.unwrap, snapshot_name, path: path)
+      @_executor.snapshot(snapshot_name, path: path)
     end
 
     # Creates a snapshot named :last the current compiled item content, with
diff --git a/lib/nanoc/rule_dsl/rule_memory_calculator.rb b/lib/nanoc/rule_dsl/rule_memory_calculator.rb
index 0c85b9c..5db8787 100644
--- a/lib/nanoc/rule_dsl/rule_memory_calculator.rb
+++ b/lib/nanoc/rule_dsl/rule_memory_calculator.rb
@@ -24,6 +24,12 @@ module Nanoc::RuleDSL
       end
     end
 
+    class PathWithoutInitialSlashError < ::Nanoc::Error
+      def initialize(rep, basic_path)
+        super("The path returned for the #{rep.inspect} item representation, “#{basic_path}”, does not start with a slash. Please ensure that all routing rules return a path that starts with a slash.")
+      end
+    end
+
     # @api private
     attr_accessor :rules_collection
 
@@ -38,9 +44,6 @@ module Nanoc::RuleDSL
     #
     # @return [Nanoc::Int::RuleMemory]
     def [](obj)
-      # FIXME: Remove this
-      obj = obj.unwrap if obj.respond_to?(:unwrap)
-
       case obj
       when Nanoc::Int::ItemRep
         new_rule_memory_for_rep(obj)
@@ -50,17 +53,10 @@ module Nanoc::RuleDSL
         raise UnsupportedObjectTypeException.new(obj)
       end
     end
-    memoize :[]
 
-    # @param [Nanoc::Int::ItemRep] rep The item representation for which to fetch
-    #   the list of snapshots
-    #
-    # @return [Array] A list of snapshots, represented as arrays where the
-    #   first element is the snapshot name (a Symbol) and the last element is
-    #   a Boolean indicating whether the snapshot is final or not
     def snapshots_defs_for(rep)
       self[rep].snapshot_actions.map do |a|
-        Nanoc::Int::SnapshotDef.new(a.snapshot_name, a.final?)
+        Nanoc::Int::SnapshotDef.new(a.snapshot_name)
       end
     end
 
@@ -70,26 +66,26 @@ module Nanoc::RuleDSL
     # @return [Nanoc::Int::RuleMemory]
     def new_rule_memory_for_rep(rep)
       dependency_tracker = Nanoc::Int::DependencyTracker::Null.new
-      view_context = @site.compiler.create_view_context(dependency_tracker)
+      view_context = @site.compiler.compilation_context.create_view_context(dependency_tracker)
 
-      executor = Nanoc::RuleDSL::RecordingExecutor.new(rep, @rules_collection, @site)
+      rule_memory = Nanoc::Int::RuleMemory.new(rep)
+      executor = Nanoc::RuleDSL::RecordingExecutor.new(rule_memory)
       rule = @rules_collection.compilation_rule_for(rep)
 
       unless rule
         raise NoRuleMemoryForItemRepException.new(rep)
       end
 
-      executor.snapshot(rep, :raw)
-      executor.snapshot(rep, :pre, final: false)
+      executor.snapshot(:raw)
       rule.apply_to(rep, executor: executor, site: @site, view_context: view_context)
-      if executor.rule_memory.any_layouts?
-        executor.snapshot(rep, :post)
+      if rule_memory.any_layouts?
+        executor.snapshot(:post)
       end
-      unless executor.rule_memory.snapshot_actions.any? { |sa| sa.snapshot_name == :last }
-        executor.snapshot(rep, :last)
+      unless rule_memory.snapshot_actions.any? { |sa| sa.snapshot_name == :last }
+        executor.snapshot(:last)
       end
 
-      executor.rule_memory
+      assign_paths_to_mem(rule_memory, rep: rep)
     end
 
     # @param [Nanoc::Int::Layout] layout
@@ -106,5 +102,35 @@ module Nanoc::RuleDSL
         rm.add_filter(res[0], res[1])
       end
     end
+
+    def assign_paths_to_mem(mem, rep:)
+      mem.map do |action|
+        if action.is_a?(Nanoc::Int::ProcessingActions::Snapshot) && action.path.nil?
+          path_from_rules = basic_path_from_rules_for(rep, action.snapshot_name)
+          if path_from_rules
+            action.copy(path: path_from_rules.to_s)
+          else
+            action
+          end
+        else
+          action
+        end
+      end
+    end
+
+    # FIXME: ugly
+    def basic_path_from_rules_for(rep, snapshot_name)
+      routing_rules = @rules_collection.routing_rules_for(rep)
+      routing_rule = routing_rules[snapshot_name]
+      return nil if routing_rule.nil?
+
+      dependency_tracker = Nanoc::Int::DependencyTracker::Null.new
+      view_context = Nanoc::ViewContext.new(reps: nil, items: nil, dependency_tracker: dependency_tracker, compilation_context: nil)
+      basic_path = routing_rule.apply_to(rep, executor: nil, site: @site, view_context: view_context)
+      if basic_path && !basic_path.start_with?('/')
+        raise PathWithoutInitialSlashError.new(rep, basic_path)
+      end
+      basic_path
+    end
   end
 end
diff --git a/lib/nanoc/spec.rb b/lib/nanoc/spec.rb
index 47b80eb..0576d55 100644
--- a/lib/nanoc/spec.rb
+++ b/lib/nanoc/spec.rb
@@ -35,7 +35,15 @@ module Nanoc
         Nanoc::ItemWithRepsView.new(item, view_context)
       end
 
-      # TODO: document
+      # Creates a new layout and adds it to the site’s collection of layouts.
+      #
+      # @param [String] content The raw layout content
+      #
+      # @param [Hash] attributes A hash containing this layout's attributes
+      #
+      # @param [Nanoc::Identifier, String] identifier This layout's identifier
+      #
+      # @return [Nanoc::ItemWithRepsView] A view for the newly created layout
       def create_layout(content, attributes, identifier)
         layout = Nanoc::Int::Layout.new(content, attributes, identifier)
         @layouts << layout
@@ -110,7 +118,7 @@ module Nanoc
           reps: @reps,
           items: @items,
           dependency_tracker: @dependency_tracker,
-          compiler: new_site.compiler,
+          compilation_context: new_site.compiler.compilation_context,
         )
       end
 
@@ -133,45 +141,13 @@ module Nanoc
           end
 
           def snapshots_defs_for(_rep)
-            [Nanoc::Int::SnapshotDef.new(:last, false)]
+            [Nanoc::Int::SnapshotDef.new(:last)]
           end
         end.new(self)
       end
 
       def new_compiler_for(site)
-        rule_memory_store = Nanoc::Int::RuleMemoryStore.new
-
-        dependency_store =
-          Nanoc::Int::DependencyStore.new(site.items.to_a + site.layouts.to_a)
-
-        checksum_store =
-          Nanoc::Int::ChecksumStore.new(site: site)
-
-        item_rep_repo = Nanoc::Int::ItemRepRepo.new
-
-        action_provider = new_action_provider
-
-        outdatedness_checker =
-          Nanoc::Int::OutdatednessChecker.new(
-            site: site,
-            checksum_store: checksum_store,
-            dependency_store: dependency_store,
-            rule_memory_store: rule_memory_store,
-            action_provider: action_provider,
-            reps: item_rep_repo,
-          )
-
-        params = {
-          compiled_content_cache: Nanoc::Int::CompiledContentCache.new,
-          checksum_store: checksum_store,
-          rule_memory_store: rule_memory_store,
-          dependency_store: dependency_store,
-          outdatedness_checker: outdatedness_checker,
-          reps: item_rep_repo,
-          action_provider: action_provider,
-        }
-
-        Nanoc::Int::Compiler.new(site, params)
+        Nanoc::Int::CompilerLoader.new.load(site, action_provider: new_action_provider)
       end
 
       def new_site
diff --git a/lib/nanoc/version.rb b/lib/nanoc/version.rb
index 5a09e1d..f850667 100644
--- a/lib/nanoc/version.rb
+++ b/lib/nanoc/version.rb
@@ -1,4 +1,4 @@
 module Nanoc
   # The current Nanoc version.
-  VERSION = '4.3.4'.freeze
+  VERSION = '4.4.6'.freeze
 end
diff --git a/nanoc.gemspec b/nanoc.gemspec
index 9105dfd..354e795 100644
--- a/nanoc.gemspec
+++ b/nanoc.gemspec
@@ -14,13 +14,13 @@ Gem::Specification.new do |s|
   s.files =
     Dir['[A-Z]*'] +
     Dir['doc/yardoc_{templates,handlers}/**/*'] +
-    Dir['{bin,lib,tasks,test}/**/*'] +
+    Dir['{bin,lib,tasks,test,spec}/**/*'] +
     ['nanoc.gemspec']
   s.executables        = ['nanoc']
   s.require_paths      = ['lib']
 
   s.rdoc_options     = ['--main', 'README.md']
-  s.extra_rdoc_files = ['ChangeLog', 'LICENSE', 'README.md', 'NEWS.md']
+  s.extra_rdoc_files = ['LICENSE', 'README.md', 'NEWS.md']
 
   s.required_ruby_version = '>= 2.1.0'
 
@@ -29,4 +29,5 @@ Gem::Specification.new do |s|
   s.add_runtime_dependency('ref', '~> 2.0')
 
   s.add_development_dependency('bundler', '>= 1.7.10', '< 2.0')
+  s.add_development_dependency('appraisal', '~> 2.1')
 end
diff --git a/spec/contributors_spec.rb b/spec/contributors_spec.rb
new file mode 100644
index 0000000..c440761
--- /dev/null
+++ b/spec/contributors_spec.rb
@@ -0,0 +1,18 @@
+describe 'list of contributors in README', chdir: false do
+  let(:contributors_in_readme) do
+    File.readlines('README.md').last.chomp("\n").split(', ')
+  end
+
+  let(:contributors_in_release_notes) do
+    File.read('NEWS.md').scan(/\[[^\]]+\]$/).map { |s| s[1..-2].split(', ') }.flatten
+  end
+
+  it 'should include everyone mentioned in NEWS.md' do
+    diff = (contributors_in_release_notes - contributors_in_readme).uniq.sort
+    expect(diff).to be_empty, "some contributors are missing from the README: #{diff.join(', ')}"
+  end
+
+  it 'should be sorted' do
+    expect(contributors_in_readme).to be_humanly_sorted
+  end
+end
diff --git a/spec/nanoc/base/checksummer_spec.rb b/spec/nanoc/base/checksummer_spec.rb
new file mode 100644
index 0000000..aa4aec5
--- /dev/null
+++ b/spec/nanoc/base/checksummer_spec.rb
@@ -0,0 +1,381 @@
+require 'tempfile'
+
+describe Nanoc::Int::Checksummer::VerboseDigest do
+  let(:digest) { described_class.new }
+
+  it 'concatenates' do
+    digest.update('foo')
+    digest.update('bar')
+    expect(digest.to_s).to eql('foobar')
+  end
+end
+
+describe Nanoc::Int::Checksummer::CompactDigest do
+  let(:digest) { described_class.new }
+
+  it 'uses SHA1 and Base64' do
+    digest.update('foo')
+    digest.update('bar')
+    expect(digest.to_s).to eql(Digest::SHA1.base64digest('foobar'))
+  end
+end
+
+describe Nanoc::Int::Checksummer do
+  subject { described_class.calc(obj, Nanoc::Int::Checksummer::VerboseDigest) }
+
+  context 'String' do
+    let(:obj) { 'hello' }
+    it { is_expected.to eql('String<hello>') }
+  end
+
+  context 'Symbol' do
+    let(:obj) { :hello }
+    it { is_expected.to eql('Symbol<hello>') }
+  end
+
+  context 'nil' do
+    let(:obj) { nil }
+    it { is_expected.to eql('NilClass<>') }
+  end
+
+  context 'true' do
+    let(:obj) { true }
+    it { is_expected.to eql('TrueClass<>') }
+  end
+
+  context 'false' do
+    let(:obj) { false }
+    it { is_expected.to eql('FalseClass<>') }
+  end
+
+  context 'Array' do
+    let(:obj) { %w(hello goodbye) }
+    it { is_expected.to eql('Array<String<hello>,String<goodbye>,>') }
+
+    context 'different order' do
+      let(:obj) { %w(goodbye hello) }
+      it { is_expected.to eql('Array<String<goodbye>,String<hello>,>') }
+    end
+
+    context 'recursive' do
+      let(:obj) { [].tap { |arr| arr << ['hello', arr] } }
+      it { is_expected.to eql('Array<Array<String<hello>,Array<recur>,>,>') }
+    end
+
+    context 'non-serializable' do
+      let(:obj) { [-> {}] }
+      it { is_expected.to match(/\AArray<Proc<#<Proc:0x.*@.*:\d+.*>>,>\z/) }
+    end
+  end
+
+  context 'Hash' do
+    let(:obj) { { 'a' => 'foo', 'b' => 'bar' } }
+    it { is_expected.to eql('Hash<String<a>=String<foo>,String<b>=String<bar>,>') }
+
+    context 'different order' do
+      let(:obj) { { 'b' => 'bar', 'a' => 'foo' } }
+      it { is_expected.to eql('Hash<String<b>=String<bar>,String<a>=String<foo>,>') }
+    end
+
+    context 'non-serializable' do
+      let(:obj) { { 'a' => -> {} } }
+      it { is_expected.to match(/\AHash<String<a>=Proc<#<Proc:0x.*@.*:\d+.*>>,>\z/) }
+    end
+
+    context 'recursive values' do
+      let(:obj) { {}.tap { |hash| hash['a'] = hash } }
+      it { is_expected.to eql('Hash<String<a>=Hash<recur>,>') }
+    end
+
+    context 'recursive keys' do
+      let(:obj) { {}.tap { |hash| hash[hash] = 'hello' } }
+      it { is_expected.to eql('Hash<Hash<recur>=String<hello>,>') }
+    end
+  end
+
+  context 'Pathname' do
+    let(:obj) { ::Pathname.new(filename) }
+
+    let(:filename) { '/tmp/whatever' }
+    let(:mtime) { 200 }
+    let(:data) { 'stuffs' }
+
+    before do
+      FileUtils.mkdir_p(File.dirname(filename))
+      File.write(filename, data)
+      File.utime(mtime, mtime, filename)
+    end
+
+    it { is_expected.to eql('Pathname<6-200>') }
+
+    context 'does not exist' do
+      before do
+        FileUtils.rm_rf(filename)
+      end
+
+      it { is_expected.to eql('Pathname<???>') }
+    end
+
+    context 'different data' do
+      let(:data) { 'other stuffs :o' }
+      it { is_expected.to eql('Pathname<15-200>') }
+    end
+  end
+
+  context 'Time' do
+    let(:obj) { Time.at(111_223) }
+    it { is_expected.to eql('Time<111223>') }
+  end
+
+  context 'Float' do
+    let(:obj) { 3.14 }
+    it { is_expected.to eql('Float<3.14>') }
+  end
+
+  context 'Fixnum/Integer' do
+    let(:obj) { 3 }
+    it { is_expected.to match(/\A(Integer|Fixnum)<3>\z/) }
+  end
+
+  context 'Nanoc::Identifier' do
+    let(:obj) { Nanoc::Identifier.new('/foo.md') }
+    it { is_expected.to eql('Nanoc::Identifier<String</foo.md>>') }
+  end
+
+  context 'Nanoc::RuleDSL::RulesCollection' do
+    let(:obj) do
+      Nanoc::RuleDSL::RulesCollection.new.tap { |rc| rc.data = data }
+    end
+
+    let(:data) { 'STUFF!' }
+
+    it { is_expected.to eql('Nanoc::RuleDSL::RulesCollection<String<STUFF!>>') }
+  end
+
+  context 'Nanoc::Int::CodeSnippet' do
+    let(:obj) { Nanoc::Int::CodeSnippet.new('asdf', '/bob.rb') }
+    it { is_expected.to eql('Nanoc::Int::CodeSnippet<String<asdf>>') }
+  end
+
+  context 'Nanoc::Int::Configuration' do
+    let(:obj) { Nanoc::Int::Configuration.new(hash: { 'foo' => 'bar' }) }
+    it { is_expected.to eql('Nanoc::Int::Configuration<Symbol<foo>=String<bar>,>') }
+  end
+
+  context 'Nanoc::Int::Item' do
+    let(:obj) { Nanoc::Int::Item.new('asdf', { 'foo' => 'bar' }, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<Symbol<foo>=String<bar>,>,identifier=Nanoc::Identifier<String</foo.md>>>') }
+
+    context 'binary' do
+      let(:filename) { File.expand_path('foo.dat') }
+      let(:content) { Nanoc::Int::BinaryContent.new(filename) }
+      let(:obj) { Nanoc::Int::Item.new(content, { 'foo' => 'bar' }, '/foo.md') }
+
+      let(:mtime) { 200 }
+      let(:data) { 'stuffs' }
+
+      before do
+        File.write(content.filename, data)
+        File.utime(mtime, mtime, content.filename)
+      end
+
+      it { is_expected.to eql('Nanoc::Int::Item<content=Nanoc::Int::BinaryContent<Pathname<6-200>>,attributes=Hash<Symbol<foo>=String<bar>,>,identifier=Nanoc::Identifier<String</foo.md>>>') }
+    end
+
+    context 'recursive attributes' do
+      before do
+        obj.attributes[:foo] = obj
+      end
+
+      it { is_expected.to eql('Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<Symbol<foo>=Nanoc::Int::Item<recur>,>,identifier=Nanoc::Identifier<String</foo.md>>>') }
+    end
+
+    context 'with checksum' do
+      let(:obj) { Nanoc::Int::Item.new('asdf', { 'foo' => 'bar' }, '/foo.md', checksum_data: 'abcdef') }
+
+      it { is_expected.to eql('Nanoc::Int::Item<checksum_data=abcdef>') }
+    end
+
+    context 'with content checksum' do
+      let(:obj) { Nanoc::Int::Item.new('asdf', { 'foo' => 'bar' }, '/foo.md', content_checksum_data: 'con-cs') }
+
+      it { is_expected.to eql('Nanoc::Int::Item<content_checksum_data=con-cs,attributes=Hash<Symbol<foo>=String<bar>,>,identifier=Nanoc::Identifier<String</foo.md>>>') }
+    end
+
+    context 'with attributes checksum' do
+      let(:obj) { Nanoc::Int::Item.new('asdf', { 'foo' => 'bar' }, '/foo.md', attributes_checksum_data: 'attr-cs') }
+
+      it { is_expected.to eql('Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes_checksum_data=attr-cs,identifier=Nanoc::Identifier<String</foo.md>>>') }
+    end
+  end
+
+  context 'Nanoc::Int::Layout' do
+    let(:obj) { Nanoc::Int::Layout.new('asdf', { 'foo' => 'bar' }, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::Int::Layout<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<Symbol<foo>=String<bar>,>,identifier=Nanoc::Identifier<String</foo.md>>>') }
+
+    context 'recursive attributes' do
+      before do
+        obj.attributes[:foo] = obj
+      end
+
+      it { is_expected.to eql('Nanoc::Int::Layout<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<Symbol<foo>=Nanoc::Int::Layout<recur>,>,identifier=Nanoc::Identifier<String</foo.md>>>') }
+    end
+
+    context 'with checksum' do
+      let(:obj) { Nanoc::Int::Layout.new('asdf', { 'foo' => 'bar' }, '/foo.md', checksum_data: 'abcdef') }
+
+      it { is_expected.to eql('Nanoc::Int::Layout<checksum_data=abcdef>') }
+    end
+  end
+
+  context 'Nanoc::ItemWithRepsView' do
+    let(:obj) { Nanoc::ItemWithRepsView.new(item, nil) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::ItemWithRepsView<Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>>') }
+  end
+
+  context 'Nanoc::Int::ItemRep' do
+    let(:obj) { Nanoc::Int::ItemRep.new(item, :pdf) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::Int::ItemRep<item=Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>,name=Symbol<pdf>>') }
+  end
+
+  context 'Nanoc::ItemRepView' do
+    let(:obj) { Nanoc::ItemRepView.new(rep, :_unused_context) }
+    let(:rep) { Nanoc::Int::ItemRep.new(item, :pdf) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::ItemRepView<Nanoc::Int::ItemRep<item=Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>,name=Symbol<pdf>>>') }
+  end
+
+  context 'Nanoc::ItemWithoutRepsView' do
+    let(:obj) { Nanoc::ItemWithoutRepsView.new(item, nil) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::ItemWithoutRepsView<Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>>') }
+  end
+
+  context 'Nanoc::LayoutView' do
+    let(:obj) { Nanoc::LayoutView.new(layout, nil) }
+    let(:layout) { Nanoc::Int::Layout.new('asdf', {}, '/foo.md') }
+
+    it { is_expected.to eql('Nanoc::LayoutView<Nanoc::Int::Layout<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>>') }
+  end
+
+  context 'Nanoc::ConfigView' do
+    let(:obj) { Nanoc::ConfigView.new(config, nil) }
+    let(:config) { Nanoc::Int::Configuration.new(hash: { 'foo' => 'bar' }) }
+
+    it { is_expected.to eql('Nanoc::ConfigView<Nanoc::Int::Configuration<Symbol<foo>=String<bar>,>>') }
+  end
+
+  context 'Nanoc::ItemCollectionWithRepsView' do
+    let(:obj) { Nanoc::ItemCollectionWithRepsView.new(wrapped, nil) }
+
+    let(:config) { Nanoc::Int::Configuration.new(hash: { 'foo' => 'bar' }) }
+
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << Nanoc::Int::Item.new('foo', {}, '/foo.md')
+        arr << Nanoc::Int::Item.new('bar', {}, '/foo.md')
+      end
+    end
+
+    it { is_expected.to eql('Nanoc::ItemCollectionWithRepsView<Nanoc::Int::IdentifiableCollection<Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<foo>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>,Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<bar>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>,>>') }
+  end
+
+  context 'Nanoc::ItemCollectionWithoutRepsView' do
+    let(:obj) { Nanoc::ItemCollectionWithoutRepsView.new(wrapped, nil) }
+
+    let(:config) { Nanoc::Int::Configuration.new(hash: { 'foo' => 'bar' }) }
+
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << Nanoc::Int::Item.new('foo', {}, '/foo.md')
+        arr << Nanoc::Int::Item.new('bar', {}, '/foo.md')
+      end
+    end
+
+    it { is_expected.to eql('Nanoc::ItemCollectionWithoutRepsView<Nanoc::Int::IdentifiableCollection<Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<foo>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>,Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<bar>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>,>>') }
+  end
+
+  context 'Nanoc::RuleDSL::RuleContext' do
+    let(:obj) { Nanoc::RuleDSL::RuleContext.new(rep: rep, site: site, executor: executor, view_context: view_context) }
+
+    let(:rep) { Nanoc::Int::ItemRep.new(item, :pdf) }
+    let(:item) { Nanoc::Int::Item.new('stuff', {}, '/stuff.md') }
+
+    let(:site) do
+      Nanoc::Int::Site.new(config: config, code_snippets: code_snippets, items: items, layouts: layouts)
+    end
+
+    let(:config) { Nanoc::Int::Configuration.new(hash: { 'foo' => 'bar' }) }
+    let(:code_snippets) { [Nanoc::Int::CodeSnippet.new('asdf', '/bob.rb')] }
+    let(:items) { [item] }
+    let(:layouts) { [Nanoc::Int::Layout.new('asdf', {}, '/foo.md')] }
+
+    let(:executor) { :_unused_ }
+    let(:view_context) { :_unused_ }
+
+    let(:expected_item_checksum) { 'Nanoc::Int::Item<content=Nanoc::Int::TextualContent<String<stuff>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</stuff.md>>>' }
+    let(:expected_item_rep_checksum) { 'Nanoc::Int::ItemRep<item=' + expected_item_checksum + ',name=Symbol<pdf>>' }
+    let(:expected_layout_checksum) { 'Nanoc::Int::Layout<content=Nanoc::Int::TextualContent<String<asdf>>,attributes=Hash<>,identifier=Nanoc::Identifier<String</foo.md>>>' }
+    let(:expected_config_checksum) { 'Nanoc::Int::Configuration<Symbol<foo>=String<bar>,>' }
+
+    let(:expected_checksum) do
+      [
+        'Nanoc::RuleDSL::RuleContext<',
+        'item=',
+        'Nanoc::ItemWithoutRepsView<' + expected_item_checksum + '>',
+        ',rep=',
+        'Nanoc::ItemRepView<' + expected_item_rep_checksum + '>',
+        ',items=',
+        'Nanoc::ItemCollectionWithoutRepsView<Array<' + expected_item_checksum + ',>>',
+        ',layouts=',
+        'Nanoc::LayoutCollectionView<Array<' + expected_layout_checksum + ',>>',
+        ',config=',
+        'Nanoc::ConfigView<' + expected_config_checksum + '>',
+        '>',
+      ].join('')
+    end
+
+    it { is_expected.to eql(expected_checksum) }
+  end
+
+  context 'Nanoc::Int::Context' do
+    let(:obj) { Nanoc::Int::Context.new(foo: 123) }
+
+    it { is_expected.to match(/\ANanoc::Int::Context<@foo=(Fixnum|Integer)<123>,>\z/) }
+  end
+
+  context 'Sass::Importers::Filesystem' do
+    let(:obj) { Sass::Importers::Filesystem.new('/foo') }
+
+    before { require 'sass' }
+
+    it { is_expected.to eql('Sass::Importers::Filesystem<root=/foo>') }
+  end
+
+  context 'other marshal-able classes' do
+    let(:obj) { klass.new('hello') }
+
+    let(:klass) do
+      Class.new do
+        def initialize(a)
+          @a = a
+        end
+      end
+    end
+
+    it { is_expected.to match(/\A#<Class:0x[0-9a-f]+><.*>\z/) }
+  end
+
+  context 'other non-marshal-able classes' do
+    let(:obj) { proc {} }
+    it { is_expected.to match(/\AProc<#<Proc:0x.*@.*:\d+.*>>\z/) }
+  end
+end
diff --git a/spec/nanoc/base/compiler_spec.rb b/spec/nanoc/base/compiler_spec.rb
new file mode 100644
index 0000000..abfff16
--- /dev/null
+++ b/spec/nanoc/base/compiler_spec.rb
@@ -0,0 +1,181 @@
+describe Nanoc::Int::Compiler do
+  let(:compiler) do
+    described_class.new(
+      site,
+      compiled_content_cache: compiled_content_cache,
+      checksum_store: checksum_store,
+      rule_memory_store: rule_memory_store,
+      action_provider: action_provider,
+      dependency_store: dependency_store,
+      outdatedness_checker: outdatedness_checker,
+      reps: reps,
+    )
+  end
+
+  let(:checksum_store)         { :__irrelevant_checksum_store }
+  let(:rule_memory_store)      { :__irrelevant_rule_memory_store }
+
+  let(:dependency_store) { Nanoc::Int::DependencyStore.new(items.to_a) }
+  let(:reps) { Nanoc::Int::ItemRepRepo.new }
+
+  let(:outdatedness_checker) { double(:outdatedness_checker) }
+  let(:action_provider) { double(:action_provider) }
+
+  let(:compiled_content_cache) { Nanoc::Int::CompiledContentCache.new(items: items) }
+
+  let(:rep) { Nanoc::Int::ItemRep.new(item, :default) }
+  let(:item) { Nanoc::Int::Item.new('<%= 1 + 2 %>', {}, '/hi.md') }
+
+  let(:other_rep) { Nanoc::Int::ItemRep.new(other_item, :default) }
+  let(:other_item) { Nanoc::Int::Item.new('other content', {}, '/other.md') }
+
+  let(:site) do
+    Nanoc::Int::Site.new(
+      config: config,
+      code_snippets: code_snippets,
+      items: items,
+      layouts: layouts,
+    )
+  end
+
+  let(:config) { Nanoc::Int::Configuration.new.with_defaults }
+  let(:layouts) { [] }
+  let(:code_snippets) { [] }
+
+  let(:items) do
+    Nanoc::Int::IdentifiableCollection.new(config).tap do |col|
+      col << item
+      col << other_item
+    end
+  end
+
+  let(:memory) do
+    [
+      Nanoc::Int::ProcessingActions::Filter.new(:erb, {}),
+      Nanoc::Int::ProcessingActions::Snapshot.new(:last, nil),
+    ]
+  end
+
+  before do
+    reps << rep
+    reps << other_rep
+
+    reps.each do |rep|
+      rep.snapshot_defs << Nanoc::Int::SnapshotDef.new(:last)
+    end
+
+    allow(outdatedness_checker).to receive(:outdated?).with(rep).and_return(true)
+    allow(outdatedness_checker).to receive(:outdated?).with(other_rep).and_return(true)
+
+    allow(action_provider).to receive(:memory_for).with(rep).and_return(memory)
+    allow(action_provider).to receive(:memory_for).with(other_rep).and_return(memory)
+  end
+
+  describe '#compile_reps' do
+    subject { compiler.send(:compile_reps) }
+
+    before do
+      allow(action_provider).to receive(:snapshots_defs_for).with(rep).and_return(snapshot_defs_for_rep)
+      allow(action_provider).to receive(:snapshots_defs_for).with(other_rep).and_return(snapshot_defs_for_rep)
+    end
+
+    let(:snapshot_defs_for_rep) do
+      [Nanoc::Int::SnapshotDef.new(:last)]
+    end
+
+    let(:snapshot_defs_for_other_rep) do
+      [Nanoc::Int::SnapshotDef.new(:last)]
+    end
+
+    it 'compiles individual reps' do
+      expect { subject }.to change { rep.snapshot_contents[:last].string }
+        .from('<%= 1 + 2 %>')
+        .to('3')
+    end
+
+    context 'exception' do
+      let(:item) { Nanoc::Int::Item.new('<%= raise "lol" %>', {}, '/hi.md') }
+
+      it 'wraps exception' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::CompilationError)
+      end
+
+      it 'contains the right item rep in the wrapped exception' do
+        expect { subject }.to raise_error do |err|
+          expect(err.item_rep).to eql(rep)
+        end
+      end
+
+      it 'contains the right wrapped exception' do
+        expect { subject }.to raise_error do |err|
+          expect(err.unwrap).to be_a(RuntimeError)
+          expect(err.unwrap.message).to eq('lol')
+        end
+      end
+    end
+  end
+
+  describe '#compile_rep' do
+    subject { compiler.send(:compile_rep, rep, is_outdated: is_outdated) }
+
+    let(:is_outdated) { true }
+
+    it 'generates expected output' do
+      expect(rep.snapshot_contents[:last].string).to eql(item.content.string)
+      subject
+      expect(rep.snapshot_contents[:last].string).to eql('3')
+    end
+
+    it 'generates notifications in the proper order' do
+      expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_started, rep).ordered
+      expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_started, rep, :erb).ordered
+      expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_ended, rep, :erb).ordered
+      expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_ended, rep).ordered
+
+      subject
+    end
+
+    context 'interrupted compilation' do
+      let(:item) { Nanoc::Int::Item.new('other=<%= @items["/other.*"].compiled_content %>', {}, '/hi.md') }
+
+      before do
+        expect(action_provider).to receive(:memory_for).with(other_rep).and_return(memory)
+      end
+
+      it 'generates expected output' do
+        expect(rep.snapshot_contents[:last].string).to eql(item.content.string)
+
+        expect { compiler.send(:compile_rep, rep, is_outdated: true) }
+          .to raise_error(Nanoc::Int::Errors::UnmetDependency)
+        compiler.send(:compile_rep, other_rep, is_outdated: true)
+        compiler.send(:compile_rep, rep, is_outdated: true)
+
+        expect(rep.snapshot_contents[:last].string).to eql('other=other content')
+      end
+
+      it 'generates notifications in the proper order' do
+        # rep 1
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_started, rep).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_started, rep, :erb).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:dependency_created, item, other_item).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_suspended, rep, anything).ordered
+
+        # rep 2
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_started, other_rep).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_started, other_rep, :erb).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_ended, other_rep, :erb).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_ended, other_rep).ordered
+
+        # rep 1 (again)
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_started, rep).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_ended, rep, :erb).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:compilation_ended, rep).ordered
+
+        expect { compiler.send(:compile_rep, rep, is_outdated: true) }
+          .to raise_error(Nanoc::Int::Errors::UnmetDependency)
+        compiler.send(:compile_rep, other_rep, is_outdated: true)
+        compiler.send(:compile_rep, rep, is_outdated: true)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/configuration_spec.rb b/spec/nanoc/base/entities/configuration_spec.rb
new file mode 100644
index 0000000..8bcf364
--- /dev/null
+++ b/spec/nanoc/base/entities/configuration_spec.rb
@@ -0,0 +1,49 @@
+describe Nanoc::Int::Configuration do
+  let(:hash) { { foo: 'bar' } }
+  let(:config) { described_class.new(hash: hash) }
+
+  describe '#key?' do
+    subject { config.key?(key) }
+
+    context 'non-existent key' do
+      let(:key) { :donkey }
+      it { is_expected.not_to be }
+    end
+
+    context 'existent key' do
+      let(:key) { :foo }
+      it { is_expected.to be }
+    end
+  end
+
+  context 'with environments defined' do
+    let(:hash) { { foo: 'bar', environments: { test: { foo: 'test-bar' }, default: { foo: 'default-bar' } } } }
+    let(:config) { described_class.new(hash: hash, env_name: env_name).with_environment }
+
+    subject { config }
+
+    context 'with existing environment' do
+      let(:env_name) { 'test' }
+
+      it 'inherits options from given environment' do
+        expect(subject[:foo]).to eq('test-bar')
+      end
+    end
+
+    context 'with unknown environment' do
+      let(:env_name) { 'wtf' }
+
+      it 'does not inherits options from any environment' do
+        expect(subject[:foo]).to eq('bar')
+      end
+    end
+
+    context 'without given environment' do
+      let(:env_name) { nil }
+
+      it 'inherits options from default environment' do
+        expect(subject[:foo]).to eq('default-bar')
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/content_spec.rb b/spec/nanoc/base/entities/content_spec.rb
new file mode 100644
index 0000000..0a66969
--- /dev/null
+++ b/spec/nanoc/base/entities/content_spec.rb
@@ -0,0 +1,193 @@
+describe Nanoc::Int::Content do
+  describe '.create' do
+    subject { described_class.create(arg, params) }
+
+    let(:params) { {} }
+
+    context 'nil arg' do
+      let(:arg) { nil }
+
+      it 'raises' do
+        expect { subject }.to raise_error(ArgumentError)
+      end
+    end
+
+    context 'content arg' do
+      let(:arg) { Nanoc::Int::TextualContent.new('foo') }
+
+      it { is_expected.to eql(arg) }
+    end
+
+    context 'with binary: true param' do
+      let(:arg) { '/foo.dat' }
+      let(:params) { { binary: true } }
+
+      it 'returns binary content' do
+        expect(subject).to be_a(Nanoc::Int::BinaryContent)
+        expect(subject.filename).to eql('/foo.dat')
+      end
+    end
+
+    context 'with binary: false param' do
+      context 'with filename param' do
+        let(:arg) { 'foo' }
+        let(:params) { { binary: false, filename: '/foo.md' } }
+
+        it 'returns textual content' do
+          expect(subject).to be_a(Nanoc::Int::TextualContent)
+          expect(subject.string).to eql('foo')
+          expect(subject.filename).to eql('/foo.md')
+        end
+      end
+
+      context 'without filename param' do
+        let(:arg) { 'foo' }
+        let(:params) { { binary: false } }
+
+        it 'returns textual content' do
+          expect(subject).to be_a(Nanoc::Int::TextualContent)
+          expect(subject.string).to eql('foo')
+          expect(subject.filename).to be_nil
+        end
+      end
+    end
+  end
+end
+
+describe Nanoc::Int::TextualContent do
+  describe '#initialize' do
+    context 'without filename' do
+      let(:content) { described_class.new('foo') }
+
+      it 'sets string and filename' do
+        expect(content.string).to eq('foo')
+        expect(content.filename).to be_nil
+      end
+    end
+
+    context 'with absolute filename' do
+      let(:content) { described_class.new('foo', filename: '/foo.md') }
+
+      it 'sets string and filename' do
+        expect(content.string).to eq('foo')
+        expect(content.filename).to eq('/foo.md')
+      end
+    end
+
+    context 'with relative filename' do
+      let(:content) { described_class.new('foo', filename: 'foo.md') }
+
+      it 'errors' do
+        expect { content }.to raise_error(ArgumentError)
+      end
+    end
+
+    context 'with proc' do
+      let(:content_proc) { -> { 'foo' } }
+      let(:content) { described_class.new(content_proc) }
+
+      it 'does not call the proc immediately' do
+        expect(content_proc).not_to receive(:call)
+
+        content
+      end
+
+      it 'sets string' do
+        expect(content_proc).to receive(:call).once.and_return('dataz')
+
+        expect(content.string).to eq('dataz')
+      end
+
+      it 'only calls the proc once' do
+        expect(content_proc).to receive(:call).once.and_return('dataz')
+
+        expect(content.string).to eq('dataz')
+        expect(content.string).to eq('dataz')
+      end
+    end
+  end
+
+  describe '#binary?' do
+    subject { content.binary? }
+    let(:content) { described_class.new('foo') }
+    it { is_expected.to eql(false) }
+  end
+
+  describe '#freeze' do
+    let(:content) { described_class.new('foo', filename: '/asdf.md') }
+
+    before do
+      content.freeze
+    end
+
+    it 'prevents changes to string' do
+      expect(content.string).to be_frozen
+      expect { content.string << 'asdf' }.to raise_frozen_error
+    end
+
+    it 'prevents changes to filename' do
+      expect(content.filename).to be_frozen
+      expect { content.filename << 'asdf' }.to raise_frozen_error
+    end
+
+    context 'with proc' do
+      let(:content) { described_class.new(proc { 'foo' }) }
+
+      it 'prevents changes to string' do
+        expect(content.string).to be_frozen
+        expect { content.string << 'asdf' }.to raise_frozen_error
+      end
+    end
+  end
+
+  describe 'marshalling' do
+    let(:content) { described_class.new('foo', filename: '/foo.md') }
+
+    it 'dumps as an array' do
+      expect(content.marshal_dump).to eq(['/foo.md', 'foo'])
+    end
+
+    it 'restores a dumped object' do
+      restored = Marshal.load(Marshal.dump(content))
+      expect(restored.string).to eq('foo')
+      expect(restored.filename).to eq('/foo.md')
+    end
+  end
+end
+
+describe Nanoc::Int::BinaryContent do
+  describe '#initialize' do
+    let(:content) { described_class.new('/foo.dat') }
+
+    it 'sets filename' do
+      expect(content.filename).to eql('/foo.dat')
+    end
+
+    context 'with relative filename' do
+      let(:content) { described_class.new('foo.dat') }
+
+      it 'errors' do
+        expect { content }.to raise_error(ArgumentError)
+      end
+    end
+  end
+
+  describe '#binary?' do
+    subject { content.binary? }
+    let(:content) { described_class.new('/foo.dat') }
+    it { is_expected.to eql(true) }
+  end
+
+  describe '#freeze' do
+    let(:content) { described_class.new('/foo.dat') }
+
+    before do
+      content.freeze
+    end
+
+    it 'prevents changes' do
+      expect(content.filename).to be_frozen
+      expect { content.filename << 'asdf' }.to raise_frozen_error
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/document_spec.rb b/spec/nanoc/base/entities/document_spec.rb
new file mode 100644
index 0000000..a8e28f0
--- /dev/null
+++ b/spec/nanoc/base/entities/document_spec.rb
@@ -0,0 +1,206 @@
+shared_examples 'a document' do
+  describe '#initialize' do
+    let(:content_arg) { 'Hello world' }
+    let(:attributes_arg) { { 'title' => 'Home' } }
+    let(:identifier_arg) { '/home.md' }
+    let(:checksum_data_arg) { 'abcdef' }
+    let(:content_checksum_data_arg) { 'con-cs' }
+    let(:attributes_checksum_data_arg) { 'attr-cs' }
+
+    subject do
+      described_class.new(
+        content_arg,
+        attributes_arg,
+        identifier_arg,
+        checksum_data: checksum_data_arg,
+        content_checksum_data: content_checksum_data_arg,
+        attributes_checksum_data: attributes_checksum_data_arg,
+      )
+    end
+
+    describe 'content arg' do
+      context 'string' do
+        it 'converts content' do
+          expect(subject.content).to be_a(Nanoc::Int::TextualContent)
+          expect(subject.content.string).to eql('Hello world')
+        end
+      end
+
+      context 'content' do
+        let(:content_arg) { Nanoc::Int::TextualContent.new('foo') }
+
+        it 'reuses content' do
+          expect(subject.content).to equal(content_arg)
+        end
+      end
+    end
+
+    describe 'attributes arg' do
+      context 'hash' do
+        it 'symbolizes attributes' do
+          expect(subject.attributes).to eq(title: 'Home')
+        end
+      end
+
+      context 'proc' do
+        call_count = 0
+        let(:attributes_arg) do
+          proc do
+            call_count += 1
+            { 'title' => 'Home' }
+          end
+        end
+
+        before do
+          call_count = 0
+        end
+
+        it 'does not call the proc immediately' do
+          expect(call_count).to eql(0)
+        end
+
+        it 'symbolizes attributes' do
+          expect(subject.attributes).to eq(title: 'Home')
+        end
+
+        it 'only calls the proc once' do
+          subject.attributes
+          subject.attributes
+          expect(call_count).to eql(1)
+        end
+      end
+    end
+
+    describe 'identifier arg' do
+      context 'string' do
+        it 'converts identifier' do
+          expect(subject.identifier).to be_a(Nanoc::Identifier)
+          expect(subject.identifier.to_s).to eql('/home.md')
+        end
+      end
+
+      context 'identifier' do
+        let(:identifier_arg) { Nanoc::Identifier.new('/foo.md') }
+
+        it 'retains identifier' do
+          expect(subject.identifier).to equal(identifier_arg)
+        end
+      end
+    end
+
+    describe 'checksum_data arg' do
+      it 'reuses checksum_data' do
+        expect(subject.checksum_data).to eql(checksum_data_arg)
+      end
+    end
+
+    describe 'content_checksum_data arg' do
+      it 'reuses content_checksum_data' do
+        expect(subject.content_checksum_data).to eql(content_checksum_data_arg)
+      end
+    end
+
+    describe 'attributes_checksum_data arg' do
+      it 'reuses attributes_checksum_data' do
+        expect(subject.attributes_checksum_data).to eql(attributes_checksum_data_arg)
+      end
+    end
+  end
+
+  describe '#freeze' do
+    let(:content_arg) { 'Hallo' }
+    let(:attributes_arg) { { foo: { bar: 'asdf' } } }
+    let(:document) { described_class.new(content_arg, attributes_arg, '/foo.md') }
+
+    before do
+      document.freeze
+    end
+
+    it 'refuses changes to content' do
+      expect { document.instance_variable_set(:@content, 'hah') }.to raise_frozen_error
+      expect { document.content.string << 'hah' }.to raise_frozen_error
+    end
+
+    it 'refuses to change attributes' do
+      expect { document.instance_variable_set(:@attributes, a: 'Hi') }.to raise_frozen_error
+      expect { document.attributes[:title] = 'Bye' }.to raise_frozen_error
+      expect { document.attributes[:foo][:bar] = 'fdsa' }.to raise_frozen_error
+    end
+
+    it 'refuses to change identifier' do
+      expect { document.identifier = '/asdf' }.to raise_frozen_error
+      expect { document.identifier.to_s << '/omg' }.to raise_frozen_error
+    end
+
+    context 'binary content' do
+      let(:content_arg) { Nanoc::Int::BinaryContent.new(File.expand_path('foo.dat')) }
+
+      it 'refuses changes to content' do
+        expect { document.instance_variable_set(:@content, 'hah') }.to raise_frozen_error
+        expect { document.content.filename << 'hah' }.to raise_frozen_error
+      end
+    end
+
+    context 'attributes block' do
+      let(:attributes_arg) { proc { { foo: { bar: 'asdf' } } } }
+
+      it 'gives access to the attributes' do
+        expect(document.attributes).to eql(foo: { bar: 'asdf' })
+      end
+
+      it 'refuses to change attributes' do
+        expect { document.instance_variable_set(:@attributes, a: 'Hi') }.to raise_frozen_error
+        expect { document.attributes[:title] = 'Bye' }.to raise_frozen_error
+        expect { document.attributes[:foo][:bar] = 'fdsa' }.to raise_frozen_error
+      end
+    end
+  end
+
+  describe 'equality' do
+    let(:content_arg_a) { 'Hello world' }
+    let(:content_arg_b) { 'Bye world' }
+
+    let(:attributes_arg_a) { { 'title' => 'Home' } }
+    let(:attributes_arg_b) { { 'title' => 'About' } }
+
+    let(:identifier_arg_a) { '/home.md' }
+    let(:identifier_arg_b) { '/home.md' }
+
+    let(:document_a) { described_class.new(content_arg_a, attributes_arg_a, identifier_arg_a) }
+    let(:document_b) { described_class.new(content_arg_b, attributes_arg_b, identifier_arg_b) }
+
+    subject { document_a == document_b }
+
+    context 'same identifier' do
+      let(:identifier_arg_a) { '/home.md' }
+      let(:identifier_arg_b) { '/home.md' }
+
+      it { is_expected.to eql(true) }
+
+      it 'has same hashes' do
+        expect(document_a.hash).to eql(document_b.hash)
+      end
+    end
+
+    context 'different identifier' do
+      let(:identifier_arg_a) { '/home.md' }
+      let(:identifier_arg_b) { '/about.md' }
+
+      it { is_expected.to eql(false) }
+
+      it 'has different hashes' do
+        expect(document_a.hash).not_to eql(document_b.hash)
+      end
+    end
+
+    context 'comparing with non-document' do
+      let(:document_b) { nil }
+
+      it { is_expected.to eql(false) }
+
+      it 'has different hashes' do
+        expect(document_a.hash).not_to eql(document_b.hash)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/identifier_spec.rb b/spec/nanoc/base/entities/identifier_spec.rb
new file mode 100644
index 0000000..21738d6
--- /dev/null
+++ b/spec/nanoc/base/entities/identifier_spec.rb
@@ -0,0 +1,460 @@
+describe Nanoc::Identifier do
+  describe '.from' do
+    subject { described_class.from(arg) }
+
+    context 'given an identifier' do
+      let(:arg) { Nanoc::Identifier.new('/foo.md') }
+
+      it 'returns an identifier' do
+        expect(subject).to be_a(Nanoc::Identifier)
+        expect(subject.to_s).to eq('/foo.md')
+        expect(subject).to be_full
+      end
+    end
+
+    context 'given a string' do
+      let(:arg) { '/foo.md' }
+
+      it 'returns an identifier' do
+        expect(subject).to be_a(Nanoc::Identifier)
+        expect(subject.to_s).to eq('/foo.md')
+        expect(subject).to be_full
+      end
+    end
+
+    context 'given something else' do
+      let(:arg) { 12_345 }
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Identifier::NonCoercibleObjectError)
+      end
+    end
+  end
+
+  describe '#initialize' do
+    context 'legacy type' do
+      it 'does not convert already clean paths' do
+        expect(described_class.new('/foo/bar/', type: :legacy).to_s).to eql('/foo/bar/')
+      end
+
+      it 'prepends slash if necessary' do
+        expect(described_class.new('foo/bar/', type: :legacy).to_s).to eql('/foo/bar/')
+      end
+
+      it 'appends slash if necessary' do
+        expect(described_class.new('/foo/bar', type: :legacy).to_s).to eql('/foo/bar/')
+      end
+
+      it 'removes double slashes at start' do
+        expect(described_class.new('//foo/bar/', type: :legacy).to_s).to eql('/foo/bar/')
+      end
+
+      it 'removes double slashes at end' do
+        expect(described_class.new('/foo/bar//', type: :legacy).to_s).to eql('/foo/bar/')
+      end
+
+      it 'freezes' do
+        identifier = described_class.new('/foo/bar/', type: :legacy)
+        expect { identifier.to_s << 'bbq' }.to raise_frozen_error
+      end
+    end
+
+    context 'full type' do
+      it 'refuses string not starting with a slash' do
+        expect { described_class.new('foo') }
+          .to raise_error(Nanoc::Identifier::InvalidIdentifierError)
+      end
+
+      it 'has proper string representation' do
+        expect(described_class.new('/foo').to_s).to eql('/foo')
+      end
+
+      it 'freezes' do
+        identifier = described_class.new('/foo/bar')
+        expect { identifier.to_s << 'bbq' }.to raise_frozen_error
+      end
+    end
+
+    context 'other type' do
+      it 'errors' do
+        expect { described_class.new('foo', type: :donkey) }
+          .to raise_error(Nanoc::Identifier::InvalidTypeError)
+      end
+    end
+
+    context 'default type' do
+      it 'is full' do
+        expect(described_class.new('/foo')).to be_full
+      end
+    end
+
+    context 'other args specified' do
+      it 'errors' do
+        expect { described_class.new('?', animal: :donkey) }
+          .to raise_error(ArgumentError)
+      end
+    end
+  end
+
+  describe '#to_s' do
+    it 'returns immutable string' do
+      expect { described_class.new('foo/', type: :legacy).to_s << 'lols' }.to raise_frozen_error
+      expect { described_class.new('/foo').to_s << 'lols' }.to raise_frozen_error
+    end
+  end
+
+  describe '#to_str' do
+    it 'returns immutable string' do
+      expect { described_class.new('/foo/bar').to_str << 'lols' }.to raise_frozen_error
+    end
+  end
+
+  describe 'Comparable' do
+    it 'can be compared' do
+      expect(described_class.new('/foo/bar') <= '/qux').to eql(true)
+    end
+  end
+
+  describe '#inspect' do
+    let(:identifier) { described_class.new('/foo/bar') }
+
+    subject { identifier.inspect }
+
+    it { should == '<Nanoc::Identifier type=full "/foo/bar">' }
+  end
+
+  describe '#== and #eql?' do
+    context 'comparing with equal identifier' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { described_class.new('/foo/bar//', type: :legacy) }
+
+      it 'is ==' do
+        expect(identifier_a).to eq(identifier_b)
+      end
+
+      it 'is eql?' do
+        expect(identifier_a).to eql(identifier_b)
+      end
+    end
+
+    context 'comparing with equal string' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { '/foo/bar/' }
+
+      it 'is ==' do
+        expect(identifier_a).to eq(identifier_b.to_s)
+      end
+
+      it 'is not eql?' do
+        expect(identifier_a).not_to eql(identifier_b.to_s)
+      end
+    end
+
+    context 'comparing with different identifier' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { described_class.new('/baz/qux//', type: :legacy) }
+
+      it 'is not ==' do
+        expect(identifier_a).not_to eq(identifier_b)
+      end
+
+      it 'is not eql?' do
+        expect(identifier_a).not_to eql(identifier_b)
+      end
+    end
+
+    context 'comparing with different string' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { '/baz/qux/' }
+
+      it 'is not equal' do
+        expect(identifier_a).not_to eq(identifier_b)
+      end
+
+      it 'is not eql?' do
+        expect(identifier_a).not_to eql(identifier_b)
+      end
+    end
+
+    context 'comparing with something that is not an identifier' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { :donkey }
+
+      it 'is not equal' do
+        expect(identifier_a).not_to eq(identifier_b)
+        expect(identifier_a).not_to eql(identifier_b)
+      end
+    end
+  end
+
+  describe '#hash' do
+    context 'equal identifiers' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { described_class.new('/foo/bar//', type: :legacy) }
+
+      it 'is the same' do
+        expect(identifier_a.hash == identifier_b.hash).to eql(true)
+      end
+    end
+
+    context 'different identifiers' do
+      let(:identifier_a) { described_class.new('//foo/bar/', type: :legacy) }
+      let(:identifier_b) { described_class.new('/monkey/', type: :legacy) }
+
+      it 'is different' do
+        expect(identifier_a.hash == identifier_b.hash).to eql(false)
+      end
+    end
+  end
+
+  describe '#=~' do
+    let(:identifier) { described_class.new('/foo/bar') }
+
+    subject { identifier =~ pat }
+
+    context 'given a regex' do
+      context 'matching regex' do
+        let(:pat) { %r{\A/foo/bar} }
+        it { is_expected.to eql(0) }
+      end
+
+      context 'non-matching regex' do
+        let(:pat) { %r{\A/qux/monkey} }
+        it { is_expected.to eql(nil) }
+      end
+    end
+
+    context 'given a string' do
+      context 'matching string' do
+        let(:pat) { '/foo/*' }
+        it { is_expected.to eql(0) }
+      end
+
+      context 'non-matching string' do
+        let(:pat) { '/qux/*' }
+        it { is_expected.to eql(nil) }
+      end
+    end
+  end
+
+  describe '#<=>' do
+    let(:identifier) { described_class.new('/foo/bar') }
+
+    it 'compares by string' do
+      expect(identifier <=> '/foo/aarghh').to eql(1)
+      expect(identifier <=> '/foo/bar').to eql(0)
+      expect(identifier <=> '/foo/qux').to eql(-1)
+    end
+  end
+
+  describe '#prefix' do
+    let(:identifier) { described_class.new('/foo') }
+
+    subject { identifier.prefix(prefix) }
+
+    context 'prefix not ending nor starting with a slash' do
+      let(:prefix) { 'asdf' }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(
+          Nanoc::Identifier::InvalidPrefixError,
+          'Invalid prefix (does not start with a slash): "asdf"',
+        )
+      end
+    end
+
+    context 'prefix ending with a slash' do
+      let(:prefix) { 'asdf/' }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(
+          Nanoc::Identifier::InvalidPrefixError,
+          'Invalid prefix (does not start with a slash): "asdf/"',
+        )
+      end
+    end
+
+    context 'prefix ending and starting with a slash' do
+      let(:prefix) { '/asdf/' }
+
+      it 'returns a proper new identifier' do
+        expect(subject).to be_a(Nanoc::Identifier)
+        expect(subject.to_s).to eql('/asdf/foo')
+      end
+    end
+
+    context 'prefix nstarting with a slash' do
+      let(:prefix) { '/asdf' }
+
+      it 'returns a proper new identifier' do
+        expect(subject).to be_a(Nanoc::Identifier)
+        expect(subject.to_s).to eql('/asdf/foo')
+      end
+    end
+  end
+
+  describe '#without_ext' do
+    subject { identifier.without_ext }
+
+    context 'legacy type' do
+      let(:identifier) { described_class.new('/foo/', type: :legacy) }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::Identifier::UnsupportedLegacyOperationError)
+      end
+    end
+
+    context 'identifier with no extension' do
+      let(:identifier) { described_class.new('/foo') }
+
+      it 'does nothing' do
+        expect(subject).to eql('/foo')
+      end
+    end
+
+    context 'identifier with extension' do
+      let(:identifier) { described_class.new('/foo.md') }
+
+      it 'removes the extension' do
+        expect(subject).to eql('/foo')
+      end
+    end
+  end
+
+  describe '#ext' do
+    subject { identifier.ext }
+
+    context 'legacy type' do
+      let(:identifier) { described_class.new('/foo/', type: :legacy) }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::Identifier::UnsupportedLegacyOperationError)
+      end
+    end
+
+    context 'identifier with no extension' do
+      let(:identifier) { described_class.new('/foo') }
+
+      it { is_expected.to be_nil }
+    end
+
+    context 'identifier with extension' do
+      let(:identifier) { described_class.new('/foo.md') }
+
+      it { is_expected.to eql('md') }
+    end
+  end
+
+  describe '#without_exts' do
+    subject { identifier.without_exts }
+
+    context 'legacy type' do
+      let(:identifier) { described_class.new('/foo/', type: :legacy) }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::Identifier::UnsupportedLegacyOperationError)
+      end
+    end
+
+    context 'identifier with no extension' do
+      let(:identifier) { described_class.new('/foo') }
+
+      it 'does nothing' do
+        expect(subject).to eql('/foo')
+      end
+    end
+
+    context 'identifier with one extension' do
+      let(:identifier) { described_class.new('/foo.md') }
+
+      it 'removes the extension' do
+        expect(subject).to eql('/foo')
+      end
+    end
+
+    context 'identifier with multiple extensions' do
+      let(:identifier) { described_class.new('/foo.html.md') }
+
+      it 'removes the extension' do
+        expect(subject).to eql('/foo')
+      end
+    end
+  end
+
+  describe '#exts' do
+    subject { identifier.exts }
+
+    context 'legacy type' do
+      let(:identifier) { described_class.new('/foo/', type: :legacy) }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::Identifier::UnsupportedLegacyOperationError)
+      end
+    end
+
+    context 'identifier with no extension' do
+      let(:identifier) { described_class.new('/foo') }
+
+      it { is_expected.to be_empty }
+    end
+
+    context 'identifier with one extension' do
+      let(:identifier) { described_class.new('/foo.md') }
+
+      it { is_expected.to eql(['md']) }
+    end
+
+    context 'identifier with multiple extensions' do
+      let(:identifier) { described_class.new('/foo.html.md') }
+
+      it { is_expected.to eql(%w(html md)) }
+    end
+  end
+
+  describe '#legacy?' do
+    subject { identifier.legacy? }
+
+    context 'legacy type' do
+      let(:identifier) { described_class.new('/foo/', type: :legacy) }
+      it { is_expected.to eql(true) }
+    end
+
+    context 'full type' do
+      let(:identifier) { described_class.new('/foo/', type: :full) }
+      it { is_expected.to eql(false) }
+    end
+  end
+
+  describe '#full?' do
+    subject { identifier.full? }
+
+    context 'legacy type' do
+      let(:identifier) { described_class.new('/foo/', type: :legacy) }
+      it { is_expected.to eql(false) }
+    end
+
+    context 'full type' do
+      let(:identifier) { described_class.new('/foo/', type: :full) }
+      it { is_expected.to eql(true) }
+    end
+  end
+
+  describe '#components' do
+    subject { identifier.components }
+
+    context 'no components' do
+      let(:identifier) { described_class.new('/') }
+      it { is_expected.to eql([]) }
+    end
+
+    context 'one component' do
+      let(:identifier) { described_class.new('/foo.md') }
+      it { is_expected.to eql(['foo.md']) }
+    end
+
+    context 'two components' do
+      let(:identifier) { described_class.new('/foo/bar.md') }
+      it { is_expected.to eql(['foo', 'bar.md']) }
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/item_rep_spec.rb b/spec/nanoc/base/entities/item_rep_spec.rb
new file mode 100644
index 0000000..ec17ae2
--- /dev/null
+++ b/spec/nanoc/base/entities/item_rep_spec.rb
@@ -0,0 +1,226 @@
+describe Nanoc::Int::ItemRep do
+  let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+  let(:rep) { Nanoc::Int::ItemRep.new(item, :giraffe) }
+
+  describe '#compiled_content' do
+    let(:snapshot_name) { raise 'override me' }
+    subject { rep.compiled_content(snapshot: snapshot_name) }
+
+    shared_examples 'a non-moving snapshot with content' do
+      context 'no snapshot def' do
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::NoSuchSnapshot)
+        end
+      end
+
+      context 'snapshot def exists' do
+        before do
+          rep.snapshot_defs = [Nanoc::Int::SnapshotDef.new(snapshot_name)]
+          rep.snapshot_contents = { snapshot_name => content }
+        end
+
+        context 'content is textual' do
+          let(:content) { Nanoc::Int::TextualContent.new('hellos') }
+          it { is_expected.to eql('hellos') }
+        end
+
+        context 'content is binary' do
+          before { File.write('donkey.dat', 'binary data') }
+          let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('donkey.dat')) }
+
+          it 'raises' do
+            expect { subject }.to raise_error(Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem)
+          end
+        end
+      end
+    end
+
+    shared_examples 'a non-moving snapshot' do
+      include_examples 'a non-moving snapshot with content'
+
+      context 'snapshot def exists, but not content' do
+        before do
+          rep.snapshot_defs = [Nanoc::Int::SnapshotDef.new(snapshot_name)]
+          rep.snapshot_contents = {}
+        end
+
+        it 'errors' do
+          expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+        end
+      end
+    end
+
+    shared_examples 'snapshot :last' do
+      context 'no snapshot def' do
+        it 'errors' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::NoSuchSnapshot)
+        end
+      end
+
+      context 'snapshot exists' do
+        context 'snapshot is not final' do
+          before do
+            rep.snapshot_defs = [Nanoc::Int::SnapshotDef.new(snapshot_name)]
+          end
+
+          context 'snapshot content does not exist' do
+            before do
+              rep.snapshot_contents = {}
+            end
+
+            it 'errors' do
+              expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+            end
+          end
+
+          context 'snapshot content exists' do
+            context 'content is textual' do
+              before do
+                rep.snapshot_contents[snapshot_name] = Nanoc::Int::TextualContent.new('hellos')
+              end
+
+              context 'not compiled' do
+                before { rep.compiled = false }
+
+                it 'raises' do
+                  expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+                end
+              end
+
+              context 'compiled' do
+                before { rep.compiled = true }
+
+                it { is_expected.to eql('hellos') }
+              end
+            end
+
+            context 'content is binary' do
+              before do
+                File.write('donkey.dat', 'binary data')
+                rep.snapshot_contents[snapshot_name] = Nanoc::Int::BinaryContent.new(File.expand_path('donkey.dat'))
+              end
+
+              context 'not compiled' do
+                before { rep.compiled = false }
+
+                it 'raises' do
+                  expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+                end
+              end
+
+              context 'compiled' do
+                before { rep.compiled = true }
+
+                it 'raises' do
+                  expect { subject }.to raise_error(Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem)
+                end
+              end
+            end
+          end
+        end
+
+        context 'snapshot is final' do
+          before do
+            rep.snapshot_defs = [Nanoc::Int::SnapshotDef.new(snapshot_name)]
+          end
+
+          context 'snapshot content does not exist' do
+            before do
+              rep.snapshot_contents = {}
+            end
+
+            it 'errors' do
+              expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+            end
+          end
+
+          context 'snapshot content exists' do
+            context 'content is textual' do
+              before do
+                rep.snapshot_contents[snapshot_name] = Nanoc::Int::TextualContent.new('hellos')
+              end
+
+              context 'not compiled' do
+                before { rep.compiled = false }
+
+                it 'errors' do
+                  expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+                end
+              end
+
+              context 'compiled' do
+                before { rep.compiled = true }
+
+                it { is_expected.to eql('hellos') }
+              end
+            end
+
+            context 'content is binary' do
+              before do
+                File.write('donkey.dat', 'binary data')
+                rep.snapshot_contents[snapshot_name] = Nanoc::Int::BinaryContent.new(File.expand_path('donkey.dat'))
+              end
+
+              context 'not compiled' do
+                before { rep.compiled = false }
+
+                it 'raises' do
+                  expect { subject }.to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+                end
+              end
+
+              context 'compiled' do
+                before { rep.compiled = true }
+
+                it 'raises' do
+                  expect { subject }.to raise_error(Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem)
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+
+    context 'snapshot nil' do
+      let(:snapshot_name) { :last }
+      subject { rep.compiled_content(snapshot: nil) }
+      include_examples 'snapshot :last'
+    end
+
+    context 'snapshot not specified' do
+      subject { rep.compiled_content }
+
+      context 'pre exists' do
+        before { rep.snapshot_contents[:pre] = Nanoc::Int::TextualContent.new('omg') }
+        let(:snapshot_name) { :pre }
+        include_examples 'a non-moving snapshot with content'
+      end
+
+      context 'pre does not exist' do
+        let(:snapshot_name) { :last }
+        include_examples 'snapshot :last'
+      end
+    end
+
+    context 'snapshot :pre specified' do
+      let(:snapshot_name) { :pre }
+      include_examples 'a non-moving snapshot'
+    end
+
+    context 'snapshot :post specified' do
+      let(:snapshot_name) { :post }
+      include_examples 'a non-moving snapshot'
+    end
+
+    context 'snapshot :last specified' do
+      let(:snapshot_name) { :last }
+      include_examples 'snapshot :last'
+    end
+
+    context 'snapshot :donkey specified' do
+      let(:snapshot_name) { :donkey }
+      include_examples 'a non-moving snapshot'
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/item_spec.rb b/spec/nanoc/base/entities/item_spec.rb
new file mode 100644
index 0000000..fceaf2e
--- /dev/null
+++ b/spec/nanoc/base/entities/item_spec.rb
@@ -0,0 +1,3 @@
+describe Nanoc::Int::Item do
+  it_behaves_like 'a document'
+end
diff --git a/spec/nanoc/base/entities/layout_spec.rb b/spec/nanoc/base/entities/layout_spec.rb
new file mode 100644
index 0000000..a15211d
--- /dev/null
+++ b/spec/nanoc/base/entities/layout_spec.rb
@@ -0,0 +1,3 @@
+describe Nanoc::Int::Layout do
+  it_behaves_like 'a document'
+end
diff --git a/spec/nanoc/base/entities/lazy_value_spec.rb b/spec/nanoc/base/entities/lazy_value_spec.rb
new file mode 100644
index 0000000..ca5f932
--- /dev/null
+++ b/spec/nanoc/base/entities/lazy_value_spec.rb
@@ -0,0 +1,106 @@
+describe Nanoc::Int::LazyValue do
+  describe '#value' do
+    let(:value_arg) { 'Hello world' }
+    let(:lazy_value) { described_class.new(value_arg) }
+
+    subject { lazy_value.value }
+
+    context 'object' do
+      it { is_expected.to equal(value_arg) }
+    end
+
+    context 'proc' do
+      it 'does not call the proc immediately' do
+        expect(value_arg).not_to receive(:call)
+
+        lazy_value
+      end
+
+      it 'returns proc return value' do
+        expect(value_arg).to receive(:call).once.and_return('Hello proc')
+
+        expect(subject).to eql('Hello proc')
+      end
+
+      it 'only calls the proc once' do
+        expect(value_arg).to receive(:call).once.and_return('Hello proc')
+
+        expect(subject).to eql('Hello proc')
+        expect(subject).to eql('Hello proc')
+      end
+    end
+  end
+
+  describe '#map' do
+    let(:value_arg) { -> { 'Hello world' } }
+    let(:lazy_value) { described_class.new(value_arg) }
+
+    subject { lazy_value.map(&:upcase) }
+
+    it 'does not call the proc immediately' do
+      expect(value_arg).not_to receive(:call)
+
+      subject
+    end
+
+    it 'returns proc return value' do
+      expect(value_arg).to receive(:call).once.and_return('Hello proc')
+
+      expect(subject.value).to eql('HELLO PROC')
+    end
+
+    it 'only calls the proc once' do
+      expect(value_arg).to receive(:call).once.and_return('Hello proc')
+
+      expect(subject.value).to eql('HELLO PROC')
+      expect(subject.value).to eql('HELLO PROC')
+    end
+  end
+
+  describe '#freeze' do
+    let(:value_arg) { 'Hello world' }
+
+    subject { described_class.new(value_arg) }
+
+    before do
+      subject.freeze
+    end
+
+    context 'object' do
+      it 'returns value' do
+        expect(subject.value).to equal(value_arg)
+      end
+
+      it 'freezes value' do
+        expect(subject.value).to be_frozen
+      end
+    end
+
+    context 'proc' do
+      call_count = 0
+      let(:value_arg) do
+        proc do
+          call_count += 1
+          'Hello proc'
+        end
+      end
+
+      before do
+        call_count = 0
+        subject.freeze
+      end
+
+      it 'does not call the proc immediately' do
+        expect(call_count).to eql(0)
+      end
+
+      it 'returns proc return value' do
+        expect(subject.value).to eq('Hello proc')
+      end
+
+      it 'freezes upon access' do
+        expect(subject.value).to be_frozen
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/outdatedness_status_spec.rb b/spec/nanoc/base/entities/outdatedness_status_spec.rb
new file mode 100644
index 0000000..319219f
--- /dev/null
+++ b/spec/nanoc/base/entities/outdatedness_status_spec.rb
@@ -0,0 +1,113 @@
+describe Nanoc::Int::OutdatednessStatus do
+  let(:status) { described_class.new }
+
+  describe '#reasons' do
+    subject { status.reasons }
+
+    context 'default' do
+      it { is_expected.to eql([]) }
+    end
+
+    context 'one passed in' do
+      let(:reasons) do
+        [
+          Nanoc::Int::OutdatednessReasons::CodeSnippetsModified,
+        ]
+      end
+
+      let(:status) { described_class.new(reasons: reasons) }
+
+      it { is_expected.to eql(reasons) }
+    end
+
+    context 'two passed in' do
+      let(:reasons) do
+        [
+          Nanoc::Int::OutdatednessReasons::CodeSnippetsModified,
+          Nanoc::Int::OutdatednessReasons::ContentModified,
+        ]
+      end
+
+      let(:status) { described_class.new(reasons: reasons) }
+
+      it { is_expected.to eql(reasons) }
+    end
+  end
+
+  describe '#props' do
+    subject { status.props.active }
+
+    context 'default' do
+      it { is_expected.to eql(Set.new) }
+    end
+
+    context 'specific one passed in' do
+      let(:props) do
+        Nanoc::Int::Props.new(attributes: true)
+      end
+
+      let(:status) { described_class.new(props: props) }
+
+      it { is_expected.to eql(Set.new([:attributes])) }
+    end
+  end
+
+  describe '#useful_to_apply' do
+    subject { status.useful_to_apply?(rule) }
+
+    let(:status) { described_class.new(props: props) }
+    let(:props) { Nanoc::Int::Props.new }
+    let(:rule) { Nanoc::Int::OutdatednessRules::RulesModified }
+
+    context 'no props' do
+      it { is_expected.to be }
+    end
+
+    context 'some props' do
+      context 'same props' do
+        let(:props) { Nanoc::Int::Props.new(compiled_content: true, path: true) }
+        it { is_expected.not_to be }
+      end
+
+      context 'different props' do
+        let(:props) { Nanoc::Int::Props.new(attributes: true) }
+        it { is_expected.to be }
+      end
+    end
+
+    context 'all props' do
+      let(:props) { Nanoc::Int::Props.new(raw_content: true, attributes: true, compiled_content: true, path: true) }
+      it { is_expected.not_to be }
+    end
+  end
+
+  describe '#update' do
+    subject { status.update(reason) }
+
+    let(:reason) { Nanoc::Int::OutdatednessReasons::ContentModified }
+
+    context 'no existing reason or props' do
+      it 'adds a reason' do
+        expect(subject.reasons).to eql([reason])
+      end
+    end
+
+    context 'existing reason' do
+      let(:status) { described_class.new(reasons: [old_reason]) }
+
+      let(:old_reason) { Nanoc::Int::OutdatednessReasons::NotWritten }
+
+      it 'adds a reason' do
+        expect(subject.reasons).to eql([old_reason, reason])
+      end
+    end
+
+    context 'existing props' do
+      let(:status) { described_class.new(props: Nanoc::Int::Props.new(attributes: true)) }
+
+      it 'updates props' do
+        expect(subject.props.active).to eql(Set.new([:raw_content, :attributes, :compiled_content]))
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/pattern_spec.rb b/spec/nanoc/base/entities/pattern_spec.rb
new file mode 100644
index 0000000..c0629e5
--- /dev/null
+++ b/spec/nanoc/base/entities/pattern_spec.rb
@@ -0,0 +1,125 @@
+describe Nanoc::Int::Pattern do
+  describe '.from' do
+    it 'converts from string' do
+      pattern = described_class.from('/foo/x[ab]z/bar.*')
+      expect(pattern.match?('/foo/xaz/bar.html')).to eql(true)
+      expect(pattern.match?('/foo/xyz/bar.html')).to eql(false)
+    end
+
+    it 'converts from regex' do
+      pattern = described_class.from(%r{\A/foo/x[ab]z/bar\..*\z})
+      expect(pattern.match?('/foo/xaz/bar.html')).to eql(true)
+      expect(pattern.match?('/foo/xyz/bar.html')).to eql(false)
+    end
+
+    it 'converts from pattern' do
+      pattern = described_class.from('/foo/x[ab]z/bar.*')
+      pattern = described_class.from(pattern)
+      expect(pattern.match?('/foo/xaz/bar.html')).to eql(true)
+      expect(pattern.match?('/foo/xyz/bar.html')).to eql(false)
+    end
+
+    it 'errors on other inputs' do
+      expect { described_class.from(123) }.to raise_error(ArgumentError)
+    end
+
+    it 'errors with a proper error message on other inputs' do
+      expect { described_class.from(nil) }
+        .to raise_error(ArgumentError, 'Do not know how to convert `nil` into a Nanoc::Pattern')
+    end
+  end
+
+  describe '#initialize' do
+    it 'errors' do
+      expect { described_class.new('/stuff') }
+        .to raise_error(NotImplementedError)
+    end
+  end
+
+  describe '#match?' do
+    it 'errors' do
+      expect { described_class.allocate.match?('/foo.md') }
+        .to raise_error(NotImplementedError)
+    end
+  end
+
+  describe '#captures' do
+    it 'errors' do
+      expect { described_class.allocate.captures('/foo.md') }
+        .to raise_error(NotImplementedError)
+    end
+  end
+end
+
+describe Nanoc::Int::RegexpPattern do
+  let(:pattern) { described_class.new(/the answer is (\d+)/) }
+
+  describe '#match?' do
+    it 'matches' do
+      expect(pattern.match?('the answer is 42')).to eql(true)
+      expect(pattern.match?('the answer is donkey')).to eql(false)
+    end
+  end
+
+  describe '#captures' do
+    it 'returns nil if it does not match' do
+      expect(pattern.captures('the answer is donkey')).to be_nil
+    end
+
+    it 'returns array if it matches' do
+      expect(pattern.captures('the answer is 42')).to eql(['42'])
+    end
+  end
+
+  describe '#to_s' do
+    subject { pattern.to_s }
+
+    it 'returns the regex' do
+      expect(subject).to eq('(?-mix:the answer is (\d+))')
+    end
+  end
+end
+
+describe Nanoc::Int::StringPattern do
+  describe '#match?' do
+    it 'matches simple strings' do
+      pattern = described_class.new('d*key')
+
+      expect(pattern.match?('donkey')).to eql(true)
+      expect(pattern.match?('giraffe')).to eql(false)
+    end
+
+    it 'matches with pathname option' do
+      pattern = described_class.new('/foo/*/bar/**/*.animal')
+
+      expect(pattern.match?('/foo/x/bar/a/b/donkey.animal')).to eql(true)
+      expect(pattern.match?('/foo/x/bar/donkey.animal')).to eql(true)
+      expect(pattern.match?('/foo/x/railroad/donkey.animal')).to eql(false)
+    end
+
+    it 'matches with extglob option' do
+      pattern = described_class.new('{b,gl}oat')
+
+      expect(pattern.match?('boat')).to eql(true)
+      expect(pattern.match?('gloat')).to eql(true)
+      expect(pattern.match?('stoat')).to eql(false)
+    end
+  end
+
+  describe '#captures' do
+    it 'returns nil' do
+      pattern = described_class.new('d*key')
+      expect(pattern.captures('donkey')).to be_nil
+    end
+  end
+
+  describe '#to_s' do
+    let(:pattern) { described_class.new('/foo/*/bar/**/*.animal') }
+
+    subject { pattern.to_s }
+
+    it 'returns the regex' do
+      expect(subject).to eq('/foo/*/bar/**/*.animal')
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/processing_action_spec.rb b/spec/nanoc/base/entities/processing_action_spec.rb
new file mode 100644
index 0000000..95793e0
--- /dev/null
+++ b/spec/nanoc/base/entities/processing_action_spec.rb
@@ -0,0 +1,9 @@
+describe Nanoc::Int::ProcessingAction do
+  let(:action) { described_class.new }
+
+  it 'is abstract' do
+    expect { action.serialize }.to raise_error(NotImplementedError)
+    expect { action.to_s }.to raise_error(NotImplementedError)
+    expect { action.inspect }.to raise_error(NotImplementedError)
+  end
+end
diff --git a/spec/nanoc/base/entities/processing_actions/filter_spec.rb b/spec/nanoc/base/entities/processing_actions/filter_spec.rb
new file mode 100644
index 0000000..4455906
--- /dev/null
+++ b/spec/nanoc/base/entities/processing_actions/filter_spec.rb
@@ -0,0 +1,18 @@
+describe Nanoc::Int::ProcessingActions::Filter do
+  let(:action) { described_class.new(:foo, awesome: true) }
+
+  describe '#serialize' do
+    subject { action.serialize }
+    it { is_expected.to eql([:filter, :foo, 'sJYzLjHGo1e4ytuDfnOLkqrt9QE=']) }
+  end
+
+  describe '#to_s' do
+    subject { action.to_s }
+    it { is_expected.to eql('filter :foo, {:awesome=>true}') }
+  end
+
+  describe '#inspect' do
+    subject { action.inspect }
+    it { is_expected.to eql('<Nanoc::Int::ProcessingActions::Filter :foo, "sJYzLjHGo1e4ytuDfnOLkqrt9QE=">') }
+  end
+end
diff --git a/spec/nanoc/base/entities/processing_actions/layout_spec.rb b/spec/nanoc/base/entities/processing_actions/layout_spec.rb
new file mode 100644
index 0000000..78d0f21
--- /dev/null
+++ b/spec/nanoc/base/entities/processing_actions/layout_spec.rb
@@ -0,0 +1,18 @@
+describe Nanoc::Int::ProcessingActions::Layout do
+  let(:action) { described_class.new('/foo.erb', awesome: true) }
+
+  describe '#serialize' do
+    subject { action.serialize }
+    it { is_expected.to eql([:layout, '/foo.erb', 'sJYzLjHGo1e4ytuDfnOLkqrt9QE=']) }
+  end
+
+  describe '#to_s' do
+    subject { action.to_s }
+    it { is_expected.to eql('layout "/foo.erb", {:awesome=>true}') }
+  end
+
+  describe '#inspect' do
+    subject { action.inspect }
+    it { is_expected.to eql('<Nanoc::Int::ProcessingActions::Layout "/foo.erb", "sJYzLjHGo1e4ytuDfnOLkqrt9QE=">') }
+  end
+end
diff --git a/spec/nanoc/base/entities/processing_actions/snapshot_spec.rb b/spec/nanoc/base/entities/processing_actions/snapshot_spec.rb
new file mode 100644
index 0000000..a06776a
--- /dev/null
+++ b/spec/nanoc/base/entities/processing_actions/snapshot_spec.rb
@@ -0,0 +1,32 @@
+describe Nanoc::Int::ProcessingActions::Snapshot do
+  let(:action) { described_class.new(:before_layout, '/foo.md') }
+
+  describe '#serialize' do
+    subject { action.serialize }
+    it { is_expected.to eql([:snapshot, :before_layout, true, '/foo.md']) }
+  end
+
+  describe '#to_s' do
+    subject { action.to_s }
+    it { is_expected.to eql('snapshot :before_layout, path: "/foo.md"') }
+  end
+
+  describe '#inspect' do
+    subject { action.inspect }
+    it { is_expected.to eql('<Nanoc::Int::ProcessingActions::Snapshot :before_layout, true, "/foo.md">') }
+  end
+
+  describe '#copy' do
+    context 'without path' do
+      subject { action.copy }
+      its(:snapshot_name) { is_expected.to eql(:before_layout) }
+      its(:path) { is_expected.to eql('/foo.md') }
+    end
+
+    context 'with path' do
+      subject { action.copy(path: '/donkey.md') }
+      its(:snapshot_name) { is_expected.to eql(:before_layout) }
+      its(:path) { is_expected.to eql('/donkey.md') }
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/props_spec.rb b/spec/nanoc/base/entities/props_spec.rb
new file mode 100644
index 0000000..b7f0ac2
--- /dev/null
+++ b/spec/nanoc/base/entities/props_spec.rb
@@ -0,0 +1,195 @@
+describe Nanoc::Int::Props do
+  let(:props) { described_class.new }
+
+  let(:props_all) do
+    described_class.new(raw_content: true, attributes: true, compiled_content: true, path: true)
+  end
+
+  describe '#inspect' do
+    subject { props.inspect }
+
+    context 'nothing active' do
+      it { is_expected.to eql('Props(____)') }
+    end
+
+    context 'attributes active' do
+      let(:props) { described_class.new(attributes: true) }
+      it { is_expected.to eql('Props(_a__)') }
+    end
+
+    context 'attributes and compiled_content active' do
+      let(:props) { described_class.new(attributes: true, compiled_content: true) }
+      it { is_expected.to eql('Props(_ac_)') }
+    end
+
+    context 'compiled_content active' do
+      let(:props) { described_class.new(compiled_content: true) }
+      it { is_expected.to eql('Props(__c_)') }
+    end
+  end
+
+  describe '#raw_content?' do
+    # …
+  end
+
+  describe '#attributes?' do
+    subject { props.attributes? }
+
+    context 'nothing active' do
+      it { is_expected.not_to be }
+    end
+
+    context 'attributes active' do
+      let(:props) { described_class.new(attributes: true) }
+      it { is_expected.to be }
+    end
+
+    context 'attributes and compiled_content active' do
+      let(:props) { described_class.new(attributes: true, compiled_content: true) }
+      it { is_expected.to be }
+    end
+
+    context 'compiled_content active' do
+      let(:props) { described_class.new(compiled_content: true) }
+      it { is_expected.not_to be }
+    end
+
+    context 'all active' do
+      let(:props) { described_class.new(raw_content: true, attributes: true, compiled_content: true, path: true) }
+      it { is_expected.to be }
+    end
+  end
+
+  describe '#compiled_content?' do
+    # …
+  end
+
+  describe '#path?' do
+    # …
+  end
+
+  describe '#merge' do
+    subject { props.merge(other_props).active }
+
+    context 'nothing + nothing' do
+      let(:props) { described_class.new }
+      let(:other_props) { described_class.new }
+
+      it { is_expected.to eql(Set.new) }
+    end
+
+    context 'nothing + some' do
+      let(:props) { described_class.new }
+      let(:other_props) { described_class.new(raw_content: true) }
+
+      it { is_expected.to eql(Set.new([:raw_content])) }
+    end
+
+    context 'nothing + all' do
+      let(:props) { described_class.new }
+      let(:other_props) { props_all }
+
+      it { is_expected.to eql(Set.new([:raw_content, :attributes, :compiled_content, :path])) }
+    end
+
+    context 'some + nothing' do
+      let(:props) { described_class.new(compiled_content: true) }
+      let(:other_props) { described_class.new }
+
+      it { is_expected.to eql(Set.new([:compiled_content])) }
+    end
+
+    context 'some + others' do
+      let(:props) { described_class.new(compiled_content: true) }
+      let(:other_props) { described_class.new(raw_content: true) }
+
+      it { is_expected.to eql(Set.new([:raw_content, :compiled_content])) }
+    end
+
+    context 'some + all' do
+      let(:props) { described_class.new(compiled_content: true) }
+      let(:other_props) { props_all }
+
+      it { is_expected.to eql(Set.new([:raw_content, :attributes, :compiled_content, :path])) }
+    end
+
+    context 'all + nothing' do
+      let(:props) { props_all }
+      let(:other_props) { described_class.new }
+
+      it { is_expected.to eql(Set.new([:raw_content, :attributes, :compiled_content, :path])) }
+    end
+
+    context 'some + all' do
+      let(:props) { props_all }
+      let(:other_props) { described_class.new(compiled_content: true) }
+
+      it { is_expected.to eql(Set.new([:raw_content, :attributes, :compiled_content, :path])) }
+    end
+
+    context 'all + all' do
+      let(:props) { props_all }
+      let(:other_props) { props_all }
+
+      it { is_expected.to eql(Set.new([:raw_content, :attributes, :compiled_content, :path])) }
+    end
+  end
+
+  describe '#active' do
+    subject { props.active }
+
+    context 'nothing active' do
+      let(:props) { described_class.new }
+      it { is_expected.to eql(Set.new) }
+    end
+
+    context 'raw_content active' do
+      let(:props) { described_class.new(raw_content: true) }
+      it { is_expected.to eql(Set.new([:raw_content])) }
+    end
+
+    context 'attributes active' do
+      let(:props) { described_class.new(attributes: true) }
+      it { is_expected.to eql(Set.new([:attributes])) }
+    end
+
+    context 'compiled_content active' do
+      let(:props) { described_class.new(compiled_content: true) }
+      it { is_expected.to eql(Set.new([:compiled_content])) }
+    end
+
+    context 'path active' do
+      let(:props) { described_class.new(path: true) }
+      it { is_expected.to eql(Set.new([:path])) }
+    end
+
+    context 'attributes and compiled_content active' do
+      let(:props) { described_class.new(attributes: true, compiled_content: true) }
+      it { is_expected.to eql(Set.new([:attributes, :compiled_content])) }
+    end
+
+    context 'all active' do
+      let(:props) { described_class.new(raw_content: true, attributes: true, compiled_content: true, path: true) }
+      it { is_expected.to eql(Set.new([:raw_content, :attributes, :compiled_content, :path])) }
+    end
+  end
+
+  describe '#to_h' do
+    subject { props.to_h }
+
+    context 'nothing' do
+      let(:props) { described_class.new }
+      it { is_expected.to eql(raw_content: false, attributes: false, compiled_content: false, path: false) }
+    end
+
+    context 'some' do
+      let(:props) { described_class.new(attributes: true, compiled_content: true) }
+      it { is_expected.to eql(raw_content: false, attributes: true, compiled_content: true, path: false) }
+    end
+
+    context 'all' do
+      let(:props) { props_all }
+      it { is_expected.to eql(raw_content: true, attributes: true, compiled_content: true, path: true) }
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/rule_memory_spec.rb b/spec/nanoc/base/entities/rule_memory_spec.rb
new file mode 100644
index 0000000..5d1a16e
--- /dev/null
+++ b/spec/nanoc/base/entities/rule_memory_spec.rb
@@ -0,0 +1,131 @@
+describe Nanoc::Int::RuleMemory do
+  let(:rule_memory) { described_class.new(rep) }
+  let(:rep) { double(:rep) }
+
+  describe '#size' do
+    subject { rule_memory.size }
+
+    context 'no actions' do
+      it { is_expected.to eql(0) }
+    end
+
+    context 'some actions' do
+      before do
+        rule_memory.add_filter(:foo, {})
+      end
+
+      it { is_expected.to eql(1) }
+    end
+  end
+
+  describe '#[]' do
+    subject { rule_memory[index] }
+    let(:index) { 0 }
+
+    context 'no actions' do
+      it { is_expected.to be_nil }
+    end
+
+    context 'some actions' do
+      before do
+        rule_memory.add_filter(:foo, {})
+      end
+
+      it { is_expected.to be_a(Nanoc::Int::ProcessingActions::Filter) }
+    end
+  end
+
+  describe '#add_filter' do
+    example do
+      rule_memory.add_filter(:foo, donkey: 123)
+
+      expect(rule_memory.size).to eql(1)
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Filter)
+      expect(rule_memory[0].filter_name).to eql(:foo)
+      expect(rule_memory[0].params).to eql(donkey: 123)
+    end
+  end
+
+  describe '#add_layout' do
+    example do
+      rule_memory.add_layout('/foo.*', donkey: 123)
+
+      expect(rule_memory.size).to eql(1)
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Layout)
+      expect(rule_memory[0].layout_identifier).to eql('/foo.*')
+      expect(rule_memory[0].params).to eql(donkey: 123)
+    end
+  end
+
+  describe '#add_snapshot' do
+    context 'snapshot does not yet exist' do
+      example do
+        rule_memory.add_snapshot(:before_layout, '/foo.md')
+
+        expect(rule_memory.size).to eql(1)
+        expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+        expect(rule_memory[0].snapshot_name).to eql(:before_layout)
+        expect(rule_memory[0].path).to eql('/foo.md')
+      end
+    end
+
+    context 'snapshot already exist' do
+      before do
+        rule_memory.add_snapshot(:before_layout, '/bar.md')
+      end
+
+      it 'raises' do
+        expect { rule_memory.add_snapshot(:before_layout, '/foo.md') }
+          .to raise_error(Nanoc::Int::Errors::CannotCreateMultipleSnapshotsWithSameName)
+      end
+    end
+  end
+
+  describe '#each' do
+    before do
+      rule_memory.add_filter(:erb, awesomeness: 'high')
+      rule_memory.add_snapshot(:bar, '/foo.md')
+      rule_memory.add_layout('/default.erb', somelayoutparam: 'yes')
+    end
+
+    example do
+      actions = []
+      rule_memory.each { |a| actions << a }
+      expect(actions.size).to eq(3)
+    end
+  end
+
+  describe '#map' do
+    before do
+      rule_memory.add_filter(:erb, awesomeness: 'high')
+      rule_memory.add_snapshot(:bar, '/foo.md')
+      rule_memory.add_layout('/default.erb', somelayoutparam: 'yes')
+    end
+
+    example do
+      res = rule_memory.map { Nanoc::Int::ProcessingActions::Filter.new(:donkey, {}) }
+      expect(res.to_a.size).to eq(3)
+      expect(res.to_a).to all(be_a(Nanoc::Int::ProcessingActions::Filter))
+    end
+  end
+
+  describe '#serialize' do
+    subject { rule_memory.serialize }
+
+    before do
+      rule_memory.add_filter(:erb, awesomeness: 'high')
+      rule_memory.add_snapshot(:bar, '/foo.md')
+      rule_memory.add_layout('/default.erb', somelayoutparam: 'yes')
+    end
+
+    example do
+      expect(subject).to eql(
+        [
+          [:filter, :erb, 'PeWUm2PtXYtqeHJdTqnY7kkwAow='],
+          [:snapshot, :bar, true, '/foo.md'],
+          [:layout, '/default.erb', '97LAe1pYTLKczxBsu+x4MmvqdkU='],
+        ],
+      )
+    end
+  end
+end
diff --git a/spec/nanoc/base/entities/site_spec.rb b/spec/nanoc/base/entities/site_spec.rb
new file mode 100644
index 0000000..a730fdd
--- /dev/null
+++ b/spec/nanoc/base/entities/site_spec.rb
@@ -0,0 +1,73 @@
+describe Nanoc::Int::Site do
+  describe '#freeze' do
+    let(:site) do
+      described_class.new(
+        config: config,
+        code_snippets: code_snippets,
+        items: items,
+        layouts: layouts,
+      )
+    end
+
+    let(:config) do
+      Nanoc::Int::Configuration.new.with_defaults
+    end
+
+    let(:code_snippets) do
+      [
+        Nanoc::Int::CodeSnippet.new('FOO = 123', 'hello.rb'),
+        Nanoc::Int::CodeSnippet.new('BAR = 123', 'hi.rb'),
+      ]
+    end
+
+    let(:items) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |coll|
+        coll << Nanoc::Int::Item.new('foo', {}, '/foo.md')
+        coll << Nanoc::Int::Item.new('bar', {}, '/bar.md')
+      end
+    end
+
+    let(:layouts) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |coll|
+        coll << Nanoc::Int::Layout.new('foo', {}, '/foo.md')
+        coll << Nanoc::Int::Layout.new('bar', {}, '/bar.md')
+      end
+    end
+
+    before do
+      site.freeze
+    end
+
+    it 'freezes the configuration' do
+      expect(site.config).to be_frozen
+    end
+
+    it 'freezes the configuration contents' do
+      expect(site.config[:output_dir]).to be_frozen
+    end
+
+    it 'freezes items collection' do
+      expect(site.items).to be_frozen
+    end
+
+    it 'freezes individual items' do
+      expect(site.items).to all(be_frozen)
+    end
+
+    it 'freezes layouts collection' do
+      expect(site.layouts).to be_frozen
+    end
+
+    it 'freezes individual layouts' do
+      expect(site.layouts).to all(be_frozen)
+    end
+
+    it 'freezes code snippets collection' do
+      expect(site.code_snippets).to be_frozen
+    end
+
+    it 'freezes individual code snippets' do
+      expect(site.code_snippets).to all(be_frozen)
+    end
+  end
+end
diff --git a/spec/nanoc/base/feature_spec.rb b/spec/nanoc/base/feature_spec.rb
new file mode 100644
index 0000000..93149c8
--- /dev/null
+++ b/spec/nanoc/base/feature_spec.rb
@@ -0,0 +1,107 @@
+describe Nanoc::Feature do
+  describe '.enabled?' do
+    subject { described_class.enabled?(feature_name) }
+
+    let(:feature_name) { 'magic' }
+
+    before do
+      Nanoc::Feature.reset_caches
+      ENV['NANOC_FEATURES'] = ''
+    end
+
+    context 'not set' do
+      it { is_expected.not_to be }
+    end
+
+    context 'set to list not including feature' do
+      before { ENV['NANOC_FEATURES'] = 'foo,bar' }
+      it { is_expected.not_to be }
+    end
+
+    context 'set to all' do
+      before { ENV['NANOC_FEATURES'] = 'all' }
+      it { is_expected.to be }
+    end
+
+    context 'set to list including feature' do
+      before { ENV['NANOC_FEATURES'] = 'foo,magic,bar' }
+      it { is_expected.to be }
+    end
+  end
+
+  describe '.enable' do
+    subject do
+      described_class.enable(feature_name) do
+        Nanoc::Feature.enabled?(feature_name)
+      end
+    end
+
+    let(:feature_name) { 'magic' }
+
+    before do
+      Nanoc::Feature.reset_caches
+      ENV['NANOC_FEATURES'] = ''
+    end
+
+    context 'not set' do
+      it { is_expected.to be }
+
+      it 'unsets afterwards' do
+        expect(Nanoc::Feature.enabled?(feature_name)).not_to be
+      end
+    end
+
+    context 'set to list not including feature' do
+      before { ENV['NANOC_FEATURES'] = 'foo,bar' }
+      it { is_expected.to be }
+
+      it 'unsets afterwards' do
+        expect(Nanoc::Feature.enabled?(feature_name)).not_to be
+      end
+    end
+
+    context 'set to all' do
+      before { ENV['NANOC_FEATURES'] = 'all' }
+      it { is_expected.to be }
+    end
+
+    context 'set to list including feature' do
+      before { ENV['NANOC_FEATURES'] = 'foo,magic,bar' }
+      it { is_expected.to be }
+    end
+  end
+
+  describe '.all_outdated' do
+    it 'refuses outdated features' do
+      # If this spec fails, there are features marked as experimental in the previous minor or major
+      # release, but not in the current one. Either remove the feature, or mark it as experimental
+      # in the current release.
+      expect(Nanoc::Feature.all_outdated).to be_empty
+    end
+
+    describe 'fake outdated features' do
+      before { Nanoc::Feature.define('abc', version: '4.2.x') }
+      after { Nanoc::Feature.undefine('abc') }
+
+      it 'detects outdated features' do
+        expect(Nanoc::Feature.all_outdated).to eq(['abc'])
+      end
+    end
+  end
+
+  describe '.define and .undefine' do
+    let(:feature_name) { 'testing123' }
+    after { Nanoc::Feature.undefine(feature_name) if defined?(Nanoc::Feature::TESTING123) }
+
+    it 'can define' do
+      Nanoc::Feature.define(feature_name, version: '4.3.x')
+      expect(Nanoc::Feature::TESTING123).not_to be_nil
+    end
+
+    it 'can undefine' do
+      Nanoc::Feature.define(feature_name, version: '4.3.x')
+      Nanoc::Feature.undefine(feature_name)
+      expect { Nanoc::Feature::TESTING123 }.to raise_error(NameError)
+    end
+  end
+end
diff --git a/spec/nanoc/base/filter_spec.rb b/spec/nanoc/base/filter_spec.rb
new file mode 100644
index 0000000..f032a92
--- /dev/null
+++ b/spec/nanoc/base/filter_spec.rb
@@ -0,0 +1,99 @@
+describe Nanoc::Filter do
+  describe '.define' do
+    context 'simple filter' do
+      let(:filter_name) { 'b5355bbb4d772b9853d21be57da614dba521dbbb' }
+      let(:filter_class) { Nanoc::Filter.named(filter_name) }
+
+      before do
+        Nanoc::Filter.define(filter_name) do |content, _params|
+          content.upcase
+        end
+      end
+
+      it 'defines a filter' do
+        expect(filter_class).not_to be_nil
+      end
+
+      it 'defines a callable filter' do
+        expect(filter_class.new.run('foo', {})).to eql('FOO')
+      end
+    end
+
+    context 'filter that accesses assigns' do
+      let(:filter_name) { 'd7ed105d460e99a3d38f46af023d9490c140fdd9' }
+      let(:filter_class) { Nanoc::Filter.named(filter_name) }
+      let(:filter) { filter_class.new(assigns) }
+      let(:assigns) { { animal: 'Giraffe' } }
+
+      before do
+        Nanoc::Filter.define(filter_name) do |_content, _params|
+          @animal
+        end
+      end
+
+      it 'can access assigns' do
+        expect(filter.setup_and_run(:__irrelevant__, {})).to eq('Giraffe')
+      end
+    end
+  end
+
+  describe '#depend_on' do
+    subject { filter.depend_on(item_views) }
+
+    let(:filter) { Nanoc::Filters::ERB.new(assigns) }
+    let(:item_views) { [item_view] }
+
+    let(:item) { Nanoc::Int::Item.new('foo', {}, '/stuff.md') }
+    let(:item_view) { Nanoc::ItemWithRepsView.new(item, view_context) }
+    let(:rep) { Nanoc::Int::ItemRep.new(item, :default) }
+
+    let(:view_context) do
+      Nanoc::ViewContext.new(
+        reps: reps,
+        items: double(:items),
+        dependency_tracker: dependency_tracker,
+        compilation_context: double(:compilation_context),
+      )
+    end
+
+    let(:dependency_tracker) { double(:dependency_tracker) }
+
+    let(:reps) { Nanoc::Int::ItemRepRepo.new }
+
+    let(:assigns) do
+      {
+        item: item_view,
+      }
+    end
+
+    before do
+      reps << rep
+
+      expect(dependency_tracker).to receive(:bounce).with(item, compiled_content: true)
+    end
+
+    context 'rep is compiled' do
+      before do
+        rep.compiled = true
+      end
+
+      example do
+        expect { subject }.not_to yield_from_fiber(an_instance_of(Nanoc::Int::Errors::UnmetDependency))
+      end
+    end
+
+    context 'rep is not compiled' do
+      example do
+        fiber = Fiber.new { subject }
+
+        # resume 1
+        res = fiber.resume
+        expect(res).to be_a(Nanoc::Int::Errors::UnmetDependency)
+        expect(res.rep).to eql(rep)
+
+        # resume 2
+        expect(fiber.resume).not_to be_a(Nanoc::Int::Errors::UnmetDependency)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/item_rep_writer_spec.rb b/spec/nanoc/base/item_rep_writer_spec.rb
new file mode 100644
index 0000000..5bfc1a1
--- /dev/null
+++ b/spec/nanoc/base/item_rep_writer_spec.rb
@@ -0,0 +1,131 @@
+describe Nanoc::Int::ItemRepWriter do
+  describe '#write' do
+    let(:raw_path) { 'output/blah.dat' }
+
+    let(:item) { Nanoc::Int::Item.new(orig_content, {}, '/') }
+
+    let(:item_rep) do
+      Nanoc::Int::ItemRep.new(item, :default).tap do |ir|
+        ir.snapshot_contents = snapshot_contents
+        ir.raw_paths = raw_paths
+      end
+    end
+
+    let(:snapshot_contents) do
+      {
+        last: Nanoc::Int::TextualContent.new('last content'),
+        donkey: Nanoc::Int::TextualContent.new('donkey content'),
+      }
+    end
+
+    let(:snapshot_name) { :donkey }
+
+    let(:raw_paths) do
+      { snapshot_name => raw_path }
+    end
+
+    subject { described_class.new.write(item_rep, snapshot_name) }
+
+    before do
+      expect(File.directory?('output')).to be_falsy
+    end
+
+    context 'binary item rep' do
+      let(:orig_content) { Nanoc::Int::BinaryContent.new(File.expand_path('foo.dat')) }
+
+      let(:snapshot_contents) do
+        {
+          last: Nanoc::Int::BinaryContent.new(File.expand_path('input-last.dat')),
+          donkey: Nanoc::Int::BinaryContent.new(File.expand_path('input-donkey.dat')),
+        }
+      end
+
+      before do
+        File.write(snapshot_contents[:last].filename, 'binary last stuff')
+        File.write(snapshot_contents[:donkey].filename, 'binary donkey stuff')
+      end
+
+      it 'copies' do
+        expect(Nanoc::Int::NotificationCenter).to receive(:post)
+          .with(:will_write_rep, item_rep, 'output/blah.dat')
+        expect(Nanoc::Int::NotificationCenter).to receive(:post)
+          .with(:rep_written, item_rep, 'output/blah.dat', true, true)
+
+        subject
+
+        expect(File.read('output/blah.dat')).to eql('binary donkey stuff')
+      end
+
+      context 'output file already exists' do
+        let(:old_mtime) { Time.at((Time.now - 600).to_i) }
+
+        before do
+          FileUtils.mkdir_p('output')
+          File.write('output/blah.dat', old_content)
+          FileUtils.touch('output/blah.dat', mtime: old_mtime)
+        end
+
+        context 'file is identical' do
+          let(:old_content) { 'binary donkey stuff' }
+
+          it 'keeps mtime' do
+            subject
+            expect(File.mtime('output/blah.dat')).to eql(old_mtime)
+          end
+        end
+
+        context 'file is not identical' do
+          let(:old_content) { 'other binary donkey stuff' }
+
+          it 'updates mtime' do
+            subject
+            expect(File.mtime('output/blah.dat')).to be > (Time.now - 1)
+          end
+        end
+      end
+    end
+
+    context 'textual item rep' do
+      let(:orig_content) { Nanoc::Int::TextualContent.new('Hallo Welt') }
+
+      it 'writes' do
+        expect(Nanoc::Int::NotificationCenter).to receive(:post)
+          .with(:will_write_rep, item_rep, 'output/blah.dat')
+        expect(Nanoc::Int::NotificationCenter).to receive(:post)
+          .with(:rep_written, item_rep, 'output/blah.dat', true, true)
+
+        subject
+
+        expect(File.read('output/blah.dat')).to eql('donkey content')
+      end
+
+      context 'output file already exists' do
+        let(:old_mtime) { Time.at((Time.now - 600).to_i) }
+
+        before do
+          FileUtils.mkdir_p('output')
+          File.write('output/blah.dat', old_content)
+          FileUtils.touch('output/blah.dat', mtime: old_mtime)
+        end
+
+        context 'file is identical' do
+          let(:old_content) { 'donkey content' }
+
+          it 'keeps mtime' do
+            subject
+            expect(File.mtime('output/blah.dat')).to eql(old_mtime)
+          end
+        end
+
+        context 'file is not identical' do
+          let(:old_content) { 'other donkey content' }
+
+          it 'updates mtime' do
+            subject
+            expect(File.mtime('output/blah.dat')).to be > (Time.now - 1)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/plugin_registry_spec.rb b/spec/nanoc/base/plugin_registry_spec.rb
new file mode 100644
index 0000000..f752fde
--- /dev/null
+++ b/spec/nanoc/base/plugin_registry_spec.rb
@@ -0,0 +1,29 @@
+describe Nanoc::Int::PluginRegistry do
+  describe '.identifier(s)' do
+    let(:identifier) { :ce79f6b8ddb22233e9aaf7d8f011689492acf02f }
+
+    context 'direct subclass' do
+      example do
+        klass =
+          Class.new(Nanoc::Filter) do
+            identifier :plugin_registry_spec
+          end
+
+        expect(klass.identifier).to eql(:plugin_registry_spec)
+      end
+    end
+
+    context 'indirect subclass' do
+      example do
+        superclass = Class.new(Nanoc::Filter)
+
+        klass =
+          Class.new(superclass) do
+            identifier :plugin_registry_spec
+          end
+
+        expect(klass.identifier).to eql(:plugin_registry_spec)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/repos/checksum_store_spec.rb b/spec/nanoc/base/repos/checksum_store_spec.rb
new file mode 100644
index 0000000..24f8f5a
--- /dev/null
+++ b/spec/nanoc/base/repos/checksum_store_spec.rb
@@ -0,0 +1,133 @@
+describe Nanoc::Int::ChecksumStore do
+  let(:store) { described_class.new(objects: objects) }
+
+  let(:objects) { [item, code_snippet] }
+
+  let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+  let(:other_item) { Nanoc::Int::Item.new('asdf', {}, '/sneaky.md') }
+
+  let(:code_snippet) { Nanoc::Int::CodeSnippet.new('def hi ; end', 'lib/foo.rb') }
+  let(:other_code_snippet) { Nanoc::Int::CodeSnippet.new('def ho ; end', 'lib/bar.rb') }
+
+  context 'nothing added' do
+    it 'has no checksum' do
+      expect(store[item]).to be_nil
+    end
+
+    it 'has no content checksum' do
+      expect(store.content_checksum_for(item)).to be_nil
+    end
+
+    it 'has no attributes checksum' do
+      expect(store.attributes_checksum_for(item)).to be_nil
+    end
+  end
+
+  context 'setting content on known non-document' do
+    before { store.add(code_snippet) }
+
+    it 'has checksum' do
+      expect(store[code_snippet]).not_to be_nil
+    end
+
+    it 'has no content checksum' do
+      expect(store.content_checksum_for(code_snippet)).to be_nil
+    end
+
+    it 'has no attributes checksum' do
+      expect(store.attributes_checksum_for(code_snippet)).to be_nil
+    end
+
+    context 'after storing and loading' do
+      before do
+        store.store
+        store.load
+      end
+
+      it 'has checksum' do
+        expect(store[code_snippet]).not_to be_nil
+      end
+    end
+  end
+
+  context 'setting content on unknown non-document' do
+    before { store.add(other_code_snippet) }
+
+    it 'has checksum' do
+      expect(store[other_code_snippet]).not_to be_nil
+    end
+
+    it 'has no content checksum' do
+      expect(store.content_checksum_for(other_code_snippet)).to be_nil
+    end
+
+    it 'has no attributes checksum' do
+      expect(store.attributes_checksum_for(other_code_snippet)).to be_nil
+    end
+
+    context 'after storing and loading' do
+      before do
+        store.store
+        store.load
+      end
+
+      it 'has no checksum' do
+        expect(store[other_code_snippet]).to be_nil
+      end
+    end
+  end
+
+  context 'setting content on known item' do
+    before { store.add(item) }
+
+    it 'has checksum' do
+      expect(store[item]).not_to be_nil
+    end
+
+    it 'has content checksum' do
+      expect(store.content_checksum_for(item)).not_to be_nil
+    end
+
+    it 'has attributes checksum' do
+      expect(store.attributes_checksum_for(item)).not_to be_nil
+    end
+
+    context 'after storing and loading' do
+      before do
+        store.store
+        store.load
+      end
+
+      it 'has checksum' do
+        expect(store[item]).not_to be_nil
+      end
+    end
+  end
+
+  context 'setting content on unknown item' do
+    before { store.add(other_item) }
+
+    it 'has checksum' do
+      expect(store[other_item]).not_to be_nil
+    end
+
+    it 'has content checksum' do
+      expect(store.content_checksum_for(other_item)).not_to be_nil
+    end
+
+    it 'has attributes checksum' do
+      expect(store.attributes_checksum_for(other_item)).not_to be_nil
+    end
+
+    context 'after storing and loading' do
+      before do
+        store.store
+        store.load
+      end
+
+      it 'has no checksum' do
+        expect(store[other_item]).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/repos/compiled_content_cache_spec.rb b/spec/nanoc/base/repos/compiled_content_cache_spec.rb
new file mode 100644
index 0000000..3ae0d4c
--- /dev/null
+++ b/spec/nanoc/base/repos/compiled_content_cache_spec.rb
@@ -0,0 +1,55 @@
+describe Nanoc::Int::CompiledContentCache do
+  let(:cache) { described_class.new(items: items) }
+
+  let(:items) { [item] }
+
+  let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo.md') }
+  let(:item_rep) { Nanoc::Int::ItemRep.new(item, :default) }
+
+  let(:other_item) { Nanoc::Int::Item.new('asdf', {}, '/sneaky.md') }
+  let(:other_item_rep) { Nanoc::Int::ItemRep.new(other_item, :default) }
+
+  let(:content) { Nanoc::Int::Content.create('omg') }
+
+  it 'has no content by default' do
+    expect(cache[item_rep]).to be_nil
+  end
+
+  context 'setting content on known item' do
+    before { cache[item_rep] = { last: content } }
+
+    it 'has content' do
+      expect(cache[item_rep][:last].string).to eql('omg')
+    end
+
+    context 'after storing and loading' do
+      before do
+        cache.store
+        cache.load
+      end
+
+      it 'has content' do
+        expect(cache[item_rep][:last].string).to eql('omg')
+      end
+    end
+  end
+
+  context 'setting content on unknown item' do
+    before { cache[other_item_rep] = { last: content } }
+
+    it 'has content' do
+      expect(cache[other_item_rep][:last].string).to eql('omg')
+    end
+
+    context 'after storing and loading' do
+      before do
+        cache.store
+        cache.load
+      end
+
+      it 'has no content' do
+        expect(cache[other_item_rep]).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/repos/config_loader_spec.rb b/spec/nanoc/base/repos/config_loader_spec.rb
new file mode 100644
index 0000000..5435f45
--- /dev/null
+++ b/spec/nanoc/base/repos/config_loader_spec.rb
@@ -0,0 +1,243 @@
+describe Nanoc::Int::ConfigLoader do
+  let(:loader) { described_class.new }
+
+  describe '#new_from_cwd' do
+    subject { loader.new_from_cwd }
+
+    context 'no config file present' do
+      it 'errors' do
+        expect { subject }.to raise_error(
+          Nanoc::Int::ConfigLoader::NoConfigFileFoundError,
+        )
+      end
+    end
+
+    context 'config file present' do
+      before do
+        File.write('nanoc.yaml', YAML.dump(foo: 'bar'))
+      end
+
+      it 'returns a configuration' do
+        expect(subject).to be_a(Nanoc::Int::Configuration)
+      end
+
+      it 'has the defaults' do
+        expect(subject[:output_dir]).to eq('output')
+      end
+
+      it 'has the custom option' do
+        expect(subject[:foo]).to eq('bar')
+      end
+    end
+
+    context 'config file and parent present' do
+      before do
+        File.write('nanoc.yaml', YAML.dump(parent_config_file: 'parent.yaml'))
+        File.write('parent.yaml', YAML.dump(foo: 'bar'))
+      end
+
+      it 'returns the configuration' do
+        expect(subject).to be_a(Nanoc::Int::Configuration)
+      end
+
+      it 'has the defaults' do
+        expect(subject[:output_dir]).to eq('output')
+      end
+
+      it 'has the custom option' do
+        expect(subject[:foo]).to eq('bar')
+      end
+
+      it 'does not include parent config option' do
+        expect(subject[:parent_config_file]).to be_nil
+      end
+    end
+
+    context 'config file present, environment defined' do
+      let(:active_env_name) { 'default' }
+
+      let(:config) do
+        {
+          foo: 'bar',
+          tofoo: 'bar',
+          environments: {
+            test: { foo: 'test-bar' },
+            default: { foo: 'default-bar' },
+          },
+        }
+      end
+
+      before do
+        File.write('nanoc.yaml', YAML.dump(config))
+      end
+
+      before do
+        expect(ENV).to receive(:fetch).with('NANOC_ENV', 'default').and_return(active_env_name)
+      end
+
+      it 'returns the configuration' do
+        expect(subject).to be_a(Nanoc::Int::Configuration)
+      end
+
+      it 'has option defined not within environments' do
+        expect(subject[:tofoo]).to eq('bar')
+      end
+
+      context 'current env is test' do
+        let(:active_env_name) { 'test' }
+
+        it 'has the test environment custom option' do
+          expect(subject[:foo]).to eq('test-bar')
+        end
+      end
+
+      it 'has the default environment custom option' do
+        expect(subject[:foo]).to eq('default-bar')
+      end
+    end
+  end
+
+  describe '.cwd_is_nanoc_site? + .config_filename_for_cwd' do
+    context 'no config files' do
+      it 'is not considered a nanoc site dir' do
+        expect(described_class.cwd_is_nanoc_site?).to eq(false)
+        expect(described_class.config_filename_for_cwd).to be_nil
+      end
+    end
+
+    context 'nanoc.yaml config file' do
+      before do
+        File.write('nanoc.yaml', 'stuff')
+      end
+
+      it 'is considered a nanoc site dir' do
+        expect(described_class.cwd_is_nanoc_site?).to eq(true)
+        expect(described_class.config_filename_for_cwd).to eq(File.expand_path('nanoc.yaml'))
+      end
+    end
+
+    context 'config.yaml config file' do
+      before do
+        File.write('config.yaml', 'stuff')
+      end
+
+      it 'is considered a nanoc site dir' do
+        expect(described_class.cwd_is_nanoc_site?).to eq(true)
+        expect(described_class.config_filename_for_cwd).to eq(File.expand_path('config.yaml'))
+      end
+    end
+  end
+
+  describe '#apply_parent_config' do
+    subject { loader.apply_parent_config(config, processed_paths) }
+
+    let(:config) { Nanoc::Int::Configuration.new(hash: { foo: 'bar' }) }
+
+    let(:processed_paths) { ['nanoc.yaml'] }
+
+    context 'no parent_config_file' do
+      it 'returns self' do
+        expect(subject).to eq(config)
+      end
+    end
+
+    context 'parent config file is set' do
+      let(:config) do
+        Nanoc::Int::Configuration.new(hash: { parent_config_file: 'foo.yaml', foo: 'bar' })
+      end
+
+      context 'parent config file is not present' do
+        it 'errors' do
+          expect { subject }.to raise_error(
+            Nanoc::Int::ConfigLoader::NoParentConfigFileFoundError,
+          )
+        end
+      end
+
+      context 'parent config file is present' do
+        context 'parent-child cycle' do
+          before do
+            File.write('foo.yaml', 'parent_config_file: bar.yaml')
+            File.write('bar.yaml', 'parent_config_file: foo.yaml')
+          end
+
+          it 'errors' do
+            expect { subject }.to raise_error(
+              Nanoc::Int::ConfigLoader::CyclicalConfigFileError,
+            )
+          end
+        end
+
+        context 'self parent-child cycle' do
+          before do
+            File.write('foo.yaml', 'parent_config_file: foo.yaml')
+          end
+
+          it 'errors' do
+            expect { subject }.to raise_error(
+              Nanoc::Int::ConfigLoader::CyclicalConfigFileError,
+            )
+          end
+        end
+
+        context 'no parent-child cycle' do
+          before do
+            File.write('foo.yaml', 'animal: giraffe')
+          end
+
+          it 'returns a configuration' do
+            expect(subject).to be_a(Nanoc::Int::Configuration)
+          end
+
+          it 'has no defaults (added in #new_from_cwd only)' do
+            expect(subject[:output_dir]).to be_nil
+          end
+
+          it 'inherits options from parent' do
+            expect(subject[:animal]).to eq('giraffe')
+          end
+
+          it 'takes options from child' do
+            expect(subject[:foo]).to eq('bar')
+          end
+
+          it 'does not include parent config option' do
+            expect(subject[:parent_config_file]).to be_nil
+          end
+        end
+
+        context 'long parent chain' do
+          before do
+            File.write('foo.yaml', "parrots: 43\nparent_config_file: bar.yaml\n")
+            File.write('bar.yaml', "day_one: lasers\nslugs: false\n")
+          end
+
+          it 'returns a configuration' do
+            expect(subject).to be_a(Nanoc::Int::Configuration)
+          end
+
+          it 'has no defaults (added in #new_from_cwd only)' do
+            expect(subject[:output_dir]).to be_nil
+          end
+
+          it 'inherits options from grandparent' do
+            expect(subject[:day_one]).to eq('lasers')
+            expect(subject[:slugs]).to eq(false)
+          end
+
+          it 'inherits options from parent' do
+            expect(subject[:parrots]).to eq(43)
+          end
+
+          it 'takes options from child' do
+            expect(subject[:foo]).to eq('bar')
+          end
+
+          it 'does not include parent config option' do
+            expect(subject[:parent_config_file]).to be_nil
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/repos/dependency_store_spec.rb b/spec/nanoc/base/repos/dependency_store_spec.rb
new file mode 100644
index 0000000..455f758
--- /dev/null
+++ b/spec/nanoc/base/repos/dependency_store_spec.rb
@@ -0,0 +1,195 @@
+describe Nanoc::Int::DependencyStore do
+  let(:store) { described_class.new(objects) }
+
+  let(:objects) do
+    [obj_a, obj_b, obj_c]
+  end
+
+  let(:obj_a) { Nanoc::Int::Item.new('a', {}, '/a.md') }
+  let(:obj_b) { Nanoc::Int::Item.new('b', {}, '/b.md') }
+  let(:obj_c) { Nanoc::Int::Item.new('c', {}, '/c.md') }
+
+  describe '#dependencies_causing_outdatedness_of' do
+    context 'no dependencies' do
+      it 'returns nothing for each' do
+        expect(store.dependencies_causing_outdatedness_of(obj_a)).to be_empty
+        expect(store.dependencies_causing_outdatedness_of(obj_b)).to be_empty
+        expect(store.dependencies_causing_outdatedness_of(obj_c)).to be_empty
+      end
+    end
+
+    context 'one dependency' do
+      context 'no props' do
+        before do
+          # FIXME: weird argument order (obj_b depends on obj_a, not th other way around)
+          store.record_dependency(obj_a, obj_b)
+        end
+
+        it 'returns one dependency' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps.size).to eql(1)
+        end
+
+        it 'returns dependency from b to a' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps[0].from).to eql(obj_b)
+          expect(deps[0].to).to eql(obj_a)
+        end
+
+        it 'returns true for all props by default' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps[0].props.raw_content?).to eq(true)
+          expect(deps[0].props.attributes?).to eq(true)
+          expect(deps[0].props.compiled_content?).to eq(true)
+          expect(deps[0].props.path?).to eq(true)
+        end
+
+        it 'returns nothing for the others' do
+          expect(store.dependencies_causing_outdatedness_of(obj_b)).to be_empty
+          expect(store.dependencies_causing_outdatedness_of(obj_c)).to be_empty
+        end
+      end
+
+      context 'one prop' do
+        before do
+          # FIXME: weird argument order (obj_b depends on obj_a, not th other way around)
+          store.record_dependency(obj_a, obj_b, compiled_content: true)
+        end
+
+        it 'returns false for all unspecified props' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps[0].props.raw_content?).to eq(false)
+          expect(deps[0].props.attributes?).to eq(false)
+          expect(deps[0].props.path?).to eq(false)
+        end
+
+        it 'returns the specified props' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps[0].props.compiled_content?).to eq(true)
+        end
+      end
+
+      context 'two props' do
+        before do
+          # FIXME: weird argument order (obj_b depends on obj_a, not th other way around)
+          store.record_dependency(obj_a, obj_b, compiled_content: true)
+          store.record_dependency(obj_a, obj_b, attributes: true)
+        end
+
+        it 'returns false for all unspecified props' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps[0].props.raw_content?).to eq(false)
+          expect(deps[0].props.path?).to eq(false)
+        end
+
+        it 'returns the specified props' do
+          deps = store.dependencies_causing_outdatedness_of(obj_a)
+          expect(deps[0].props.attributes?).to eq(true)
+          expect(deps[0].props.compiled_content?).to eq(true)
+        end
+      end
+    end
+
+    context 'two dependency in a chain' do
+      before do
+        # FIXME: weird argument order (obj_b depends on obj_a, not th other way around)
+        store.record_dependency(obj_a, obj_b)
+        store.record_dependency(obj_b, obj_c)
+      end
+
+      it 'returns one dependency for object A' do
+        deps = store.dependencies_causing_outdatedness_of(obj_a)
+        expect(deps.size).to eql(1)
+        expect(deps[0].from).to eql(obj_b)
+      end
+
+      it 'returns one dependency for object B' do
+        deps = store.dependencies_causing_outdatedness_of(obj_b)
+        expect(deps.size).to eql(1)
+        expect(deps[0].from).to eql(obj_c)
+      end
+
+      it 'returns nothing for the others' do
+        expect(store.dependencies_causing_outdatedness_of(obj_c)).to be_empty
+      end
+    end
+  end
+
+  describe 'reloading' do
+    before do
+      store.record_dependency(obj_a, obj_b, compiled_content: true)
+      store.record_dependency(obj_a, obj_b, attributes: true)
+
+      store.store
+      store.objects = objects_after
+      store.load
+    end
+
+    context 'no new objects' do
+      let(:objects_after) { objects }
+
+      it 'has the right dependencies for item A' do
+        deps = store.dependencies_causing_outdatedness_of(obj_a)
+        expect(deps.size).to eql(1)
+
+        expect(deps[0].from).to eql(obj_b)
+        expect(deps[0].to).to eql(obj_a)
+
+        expect(deps[0].props.raw_content?).to eq(false)
+        expect(deps[0].props.attributes?).to eq(true)
+        expect(deps[0].props.compiled_content?).to eq(true)
+        expect(deps[0].props.path?).to eq(false)
+      end
+
+      it 'has the right dependencies for item B' do
+        deps = store.dependencies_causing_outdatedness_of(obj_b)
+        expect(deps).to be_empty
+      end
+
+      it 'has the right dependencies for item C' do
+        deps = store.dependencies_causing_outdatedness_of(obj_c)
+        expect(deps).to be_empty
+      end
+    end
+
+    context 'one new object' do
+      let(:objects_after) do
+        [obj_a, obj_b, obj_c, obj_d]
+      end
+
+      let(:obj_d) { Nanoc::Int::Item.new('d', {}, '/d.md') }
+
+      it 'marks existing items as outdated' do
+        expect(store.objects_causing_outdatedness_of(obj_a)).to eq([obj_d])
+        expect(store.objects_causing_outdatedness_of(obj_b)).to eq([obj_d])
+        expect(store.objects_causing_outdatedness_of(obj_c)).to eq([obj_d])
+      end
+
+      it 'marks new items as outdated' do
+        expect(store.objects_causing_outdatedness_of(obj_d)).to eq([obj_d])
+      end
+    end
+
+    context 'two new objects' do
+      let(:objects_after) do
+        [obj_a, obj_b, obj_c, obj_d, obj_e]
+      end
+
+      let(:obj_d) { Nanoc::Int::Item.new('d', {}, '/d.md') }
+      let(:obj_e) { Nanoc::Int::Item.new('e', {}, '/e.md') }
+
+      it 'marks existing items as outdated' do
+        # Only one of obj D or E needed!
+        expect(store.objects_causing_outdatedness_of(obj_a)).to eq([obj_d]).or eq([obj_e])
+        expect(store.objects_causing_outdatedness_of(obj_b)).to eq([obj_d]).or eq([obj_e])
+        expect(store.objects_causing_outdatedness_of(obj_c)).to eq([obj_d]).or eq([obj_e])
+      end
+
+      it 'marks new items as outdated' do
+        # Only one of obj D or E needed!
+        expect(store.objects_causing_outdatedness_of(obj_d)).to eq([obj_d]).or eq([obj_e])
+        expect(store.objects_causing_outdatedness_of(obj_e)).to eq([obj_d]).or eq([obj_e])
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/repos/site_loader_spec.rb b/spec/nanoc/base/repos/site_loader_spec.rb
new file mode 100644
index 0000000..99aab07
--- /dev/null
+++ b/spec/nanoc/base/repos/site_loader_spec.rb
@@ -0,0 +1,214 @@
+describe Nanoc::Int::SiteLoader do
+  let(:loader) { described_class.new }
+
+  describe '#new_empty' do
+    subject { loader.new_empty }
+
+    it 'has the default configuration' do
+      expect(subject.config).to be_a(Nanoc::Int::Configuration)
+      expect(subject.config[:index_filenames]).to eq(['index.html'])
+    end
+
+    it 'has no code snippets' do
+      expect(subject.code_snippets).to be_empty
+    end
+
+    it 'has no items' do
+      expect(subject.items).to be_empty
+    end
+
+    it 'has no layouts' do
+      expect(subject.layouts).to be_empty
+    end
+  end
+
+  describe '#new_with_config' do
+    subject { loader.new_with_config(arg) }
+
+    let(:arg) { { foo: 'bar' } }
+
+    it 'has a slightly modified configuration' do
+      expect(subject.config).to be_a(Nanoc::Int::Configuration)
+      expect(subject.config[:index_filenames]).to eq(['index.html'])
+      expect(subject.config[:foo]).to eq('bar')
+    end
+
+    it 'has no code snippets' do
+      expect(subject.code_snippets).to be_empty
+    end
+
+    it 'has no items' do
+      expect(subject.items).to be_empty
+    end
+
+    it 'has no layouts' do
+      expect(subject.layouts).to be_empty
+    end
+  end
+
+  describe '#new_from_cwd' do
+    subject { loader.new_from_cwd }
+
+    context 'no config file' do
+      it 'errors' do
+        expect { subject }.to raise_error(
+          Nanoc::Int::ConfigLoader::NoConfigFileFoundError,
+        )
+      end
+    end
+
+    shared_examples 'a directory with a config file' do
+      it 'has the default configuration' do
+        expect(subject.config).to be_a(Nanoc::Int::Configuration)
+        expect(subject.config[:index_filenames]).to eq(['index.html'])
+        expect(subject.config[:foo]).to eq('bar')
+      end
+
+      it 'has no code snippets' do
+        expect(subject.code_snippets).to be_empty
+      end
+
+      it 'has no items' do
+        expect(subject.items).to be_empty
+      end
+
+      it 'has no layouts' do
+        expect(subject.layouts).to be_empty
+      end
+
+      context 'some items, layouts, and code snippets' do
+        before do
+          FileUtils.mkdir_p('lib')
+          File.write('lib/foo.rb', '$spirit_animal = :donkey')
+
+          FileUtils.mkdir_p('content')
+          File.write('content/about.md', 'I am Denis!')
+
+          FileUtils.mkdir_p('layouts')
+          File.write('layouts/page.erb', '<html><%= yield %></html>')
+        end
+
+        it 'has a code snippet' do
+          expect(subject.code_snippets.size).to eq(1)
+          expect(subject.code_snippets[0].data).to eq('$spirit_animal = :donkey')
+        end
+
+        it 'has an item' do
+          expect(subject.items.size).to eq(1)
+          expect(subject.items['/about.md'].content).to be_a(Nanoc::Int::TextualContent)
+          expect(subject.items['/about.md'].content.string).to eq('I am Denis!')
+          expect(subject.items['/about.md'].attributes[:content_filename])
+            .to eq('content/about.md')
+          expect(subject.items['/about.md'].attributes[:extension])
+            .to eq('md')
+          expect(subject.items['/about.md'].attributes[:filename])
+            .to eq('content/about.md')
+          expect(subject.items['/about.md'].attributes[:meta_filename])
+            .to be_nil
+          expect(subject.items['/about.md'].attributes[:mtime])
+            .to be > Time.now - 5
+          expect(subject.items['/about.md'].identifier.to_s).to eq('/about.md')
+        end
+
+        it 'has a layout' do
+          expect(subject.layouts.size).to eq(1)
+          expect(subject.layouts['/page.erb'].content).to be_a(Nanoc::Int::TextualContent)
+          expect(subject.layouts['/page.erb'].content.string).to eq('<html><%= yield %></html>')
+          expect(subject.layouts['/page.erb'].attributes[:content_filename])
+            .to eq('layouts/page.erb')
+          expect(subject.layouts['/page.erb'].attributes[:extension])
+            .to eq('erb')
+          expect(subject.layouts['/page.erb'].attributes[:filename])
+            .to eq('layouts/page.erb')
+          expect(subject.layouts['/page.erb'].attributes[:meta_filename])
+            .to be_nil
+          expect(subject.layouts['/page.erb'].attributes[:mtime])
+            .to be > Time.now - 5
+          expect(subject.layouts['/page.erb'].identifier.to_s).to eq('/page.erb')
+        end
+      end
+    end
+
+    context 'nanoc.yaml config file' do
+      before do
+        File.write('nanoc.yaml', "---\nfoo: bar\n")
+      end
+
+      it_behaves_like 'a directory with a config file'
+    end
+
+    context 'config.yaml config file' do
+      before do
+        File.write('config.yaml', "---\nfoo: bar\n")
+      end
+
+      it_behaves_like 'a directory with a config file'
+    end
+
+    context 'configuration has non-existant data source' do
+      before do
+        File.write('nanoc.yaml', <<-EOS.gsub(/^ {10}/, ''))
+          data_sources:
+            - type: eenvaleed
+        EOS
+      end
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::UnknownDataSource)
+      end
+    end
+
+    context 'environments defined' do
+      before do
+        File.write('nanoc.yaml', <<-EOS.gsub(/^ {10}/, ''))
+          animal: donkey
+          environments:
+            staging:
+              animal: giraffe
+        EOS
+      end
+
+      before do
+        expect(ENV).to receive(:fetch).with('NANOC_ENV', 'default').and_return('staging')
+      end
+
+      it 'does not load environment' do
+        expect(subject.config[:animal]).to eq('giraffe')
+      end
+    end
+
+    context 'code snippet with data source implementation' do
+      before do
+        FileUtils.mkdir_p('lib')
+        File.write('lib/foo_data_source.rb', <<-EOS.gsub(/^ {10}/, ''))
+          class FooDataSource < Nanoc::DataSource
+            identifier :site_loader_spec_sample
+
+            def items
+              [
+                Nanoc::Int::Item.new(
+                  'Generated content!',
+                  { generated: true },
+                  '/generated.txt',
+                )
+              ]
+            end
+          end
+        EOS
+
+        File.write('nanoc.yaml', <<-EOS.gsub(/^ {10}/, ''))
+          data_sources:
+            - type: site_loader_spec_sample
+        EOS
+      end
+
+      it 'loads code snippets before items/layouts' do
+        expect(subject.items.size).to eq(1)
+        expect(subject.items['/generated.txt'].content).to be_a(Nanoc::Int::TextualContent)
+        expect(subject.items['/generated.txt'].content.string).to eq('Generated content!')
+        expect(subject.items['/generated.txt'].attributes).to eq(generated: true)
+        expect(subject.items['/generated.txt'].identifier.to_s).to eq('/generated.txt')
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/dependency_tracker_spec.rb b/spec/nanoc/base/services/dependency_tracker_spec.rb
new file mode 100644
index 0000000..04d370e
--- /dev/null
+++ b/spec/nanoc/base/services/dependency_tracker_spec.rb
@@ -0,0 +1,238 @@
+describe Nanoc::Int::DependencyTracker do
+  let(:tracker) { described_class.new(store) }
+
+  let(:store) { Nanoc::Int::DependencyStore.new([]) }
+
+  let(:item_a) { Nanoc::Int::Item.new('a', {}, '/a.md') }
+  let(:item_b) { Nanoc::Int::Item.new('b', {}, '/b.md') }
+  let(:item_c) { Nanoc::Int::Item.new('c', {}, '/c.md') }
+
+  shared_examples 'a null dependency tracker' do
+    let(:tracker) { Nanoc::Int::DependencyTracker::Null.new }
+
+    example do
+      expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_a) }
+    end
+
+    example do
+      expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+    end
+
+    example do
+      expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+    end
+
+    example do
+      expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_a) }
+    end
+
+    example do
+      expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_b) }
+    end
+
+    example do
+      expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_c) }
+    end
+  end
+
+  describe '#enter and #exit' do
+    context 'enter' do
+      subject do
+        tracker.enter(item_a)
+      end
+
+      it_behaves_like 'a null dependency tracker'
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_a) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+      end
+    end
+
+    context 'enter + enter' do
+      subject do
+        tracker.enter(item_a)
+        tracker.enter(item_b)
+      end
+
+      it_behaves_like 'a null dependency tracker'
+
+      it 'changes predecessors of item A' do
+        expect { subject }.to change { store.objects_causing_outdatedness_of(item_a) }
+          .from([]).to([item_b])
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+      end
+    end
+
+    context 'enter + enter with props' do
+      subject do
+        tracker.enter(item_a)
+        tracker.enter(item_b, compiled_content: true)
+      end
+
+      it_behaves_like 'a null dependency tracker'
+
+      it 'changes predecessors of item A' do
+        expect { subject }.to change { store.objects_causing_outdatedness_of(item_a) }
+          .from([]).to([item_b])
+      end
+
+      it 'changes dependencies causing outdatedness of item A' do
+        expect { subject }.to change { store.dependencies_causing_outdatedness_of(item_a).size }
+          .from(0).to(1)
+      end
+
+      it 'creates correct new dependency causing outdatedness of item A' do
+        subject
+        dep = store.dependencies_causing_outdatedness_of(item_a)[0]
+
+        expect(dep.from).to eql(item_b)
+        expect(dep.to).to eql(item_a)
+      end
+
+      it 'creates dependency with correct props causing outdatedness of item A' do
+        subject
+        dep = store.dependencies_causing_outdatedness_of(item_a)[0]
+
+        expect(dep.props.compiled_content?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.attributes?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_c) }
+      end
+    end
+
+    context 'enter + enter with prop + exit + enter with prop' do
+      subject do
+        tracker.enter(item_a)
+        tracker.enter(item_b, compiled_content: true)
+        tracker.exit
+        tracker.enter(item_b, attributes: true)
+      end
+
+      it_behaves_like 'a null dependency tracker'
+
+      it 'changes predecessors of item A' do
+        expect { subject }.to change { store.objects_causing_outdatedness_of(item_a) }
+          .from([]).to([item_b])
+      end
+
+      it 'changes dependencies causing outdatedness of item A' do
+        expect { subject }.to change { store.dependencies_causing_outdatedness_of(item_a).size }
+          .from(0).to(1)
+      end
+
+      it 'creates correct new dependency causing outdatedness of item A' do
+        subject
+        dep = store.dependencies_causing_outdatedness_of(item_a)[0]
+
+        expect(dep.from).to eql(item_b)
+        expect(dep.to).to eql(item_a)
+      end
+
+      it 'creates dependency with correct props causing outdatedness of item A' do
+        subject
+        dep = store.dependencies_causing_outdatedness_of(item_a)[0]
+
+        expect(dep.props.compiled_content?).to eq(true)
+        expect(dep.props.attributes?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.dependencies_causing_outdatedness_of(item_c) }
+      end
+    end
+
+    context 'enter + enter + exit + enter' do
+      subject do
+        tracker.enter(item_a)
+        tracker.enter(item_b)
+        tracker.exit
+        tracker.enter(item_c)
+      end
+
+      it_behaves_like 'a null dependency tracker'
+
+      it 'changes predecessors of item A' do
+        expect { subject }.to change { store.objects_causing_outdatedness_of(item_a) }
+          .from([]).to([item_b, item_c])
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+      end
+    end
+
+    context 'enter + bounce + enter' do
+      subject do
+        tracker.enter(item_a)
+        tracker.bounce(item_b)
+        tracker.enter(item_c)
+      end
+
+      it_behaves_like 'a null dependency tracker'
+
+      it 'changes predecessors of item A' do
+        expect { subject }.to change { store.objects_causing_outdatedness_of(item_a) }
+          .from([]).to([item_b, item_c])
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_b) }
+      end
+
+      example do
+        expect { subject }.not_to change { store.objects_causing_outdatedness_of(item_c) }
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/executor_spec.rb b/spec/nanoc/base/services/executor_spec.rb
new file mode 100644
index 0000000..2e66751
--- /dev/null
+++ b/spec/nanoc/base/services/executor_spec.rb
@@ -0,0 +1,495 @@
+describe Nanoc::Int::Executor do
+  let(:executor) { described_class.new(rep, compilation_context, dependency_tracker) }
+
+  let(:compilation_context) do
+    Nanoc::Int::Compiler::CompilationContext.new(
+      action_provider: action_provider,
+      reps: reps,
+      site: site,
+      compiled_content_cache: compiled_content_cache,
+    )
+  end
+
+  let(:item) { Nanoc::Int::Item.new(content, {}, '/index.md') }
+  let(:rep) { Nanoc::Int::ItemRep.new(item, :donkey) }
+  let(:content) { Nanoc::Int::TextualContent.new('Donkey Power').tap(&:freeze) }
+
+  let(:action_provider) { double(:action_provider) }
+  let(:reps) { double(:reps) }
+  let(:site) { double(:site) }
+  let(:compiled_content_cache) { double(:compiled_content_cache) }
+
+  let(:dependency_tracker) { Nanoc::Int::DependencyTracker.new(double(:dependency_store)) }
+
+  describe '#filter' do
+    let(:assigns) { {} }
+
+    let(:content) { Nanoc::Int::TextualContent.new('<%= "Donkey" %> Power') }
+
+    before do
+      allow(compilation_context).to receive(:assigns_for) { assigns }
+    end
+
+    context 'normal flow with textual rep' do
+      before do
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_started, rep, :erb)
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_ended, rep, :erb)
+      end
+
+      example do
+        executor.filter(:erb)
+
+        expect(rep.snapshot_contents[:last].string).to eq('Donkey Power')
+        expect(rep.snapshot_contents[:pre]).to be_nil
+        expect(rep.snapshot_contents[:post]).to be_nil
+      end
+
+      it 'returns frozen data' do
+        executor.filter(:erb)
+
+        expect(rep.snapshot_contents[:last]).to be_frozen
+      end
+    end
+
+    context 'normal flow with binary rep' do
+      let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('foo.dat')) }
+
+      before do
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_started, rep, :whatever)
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_ended, rep, :whatever)
+
+        File.write(content.filename, 'Foo Data')
+
+        filter_class = Class.new(::Nanoc::Filter) do
+          type :binary
+
+          def run(filename, _params = {})
+            File.write(output_filename, "Compiled data for #{filename}")
+          end
+        end
+
+        expect(Nanoc::Filter).to receive(:named).with(:whatever) { filter_class }
+      end
+
+      example do
+        executor.filter(:whatever)
+
+        expect(File.read(rep.snapshot_contents[:last].filename))
+          .to match(/\ACompiled data for \/.*\/foo.dat\z/)
+        expect(rep.snapshot_contents[:pre]).to be_nil
+        expect(rep.snapshot_contents[:post]).to be_nil
+      end
+
+      it 'returns frozen data' do
+        executor.filter(:whatever)
+
+        expect(rep.snapshot_contents[:last]).to be_frozen
+      end
+    end
+
+    context 'normal flow with binary rep and binary-to-text filter' do
+      let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('foo.dat')) }
+
+      before do
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_started, rep, :whatever)
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_ended, rep, :whatever)
+
+        File.write(content.filename, 'Foo Data')
+
+        filter_class = Class.new(::Nanoc::Filter) do
+          type binary: :text
+
+          def run(filename, _params = {})
+            "Compiled data for #{filename}"
+          end
+        end
+
+        expect(Nanoc::Filter).to receive(:named).with(:whatever) { filter_class }
+      end
+
+      example do
+        executor.filter(:whatever)
+
+        expect(rep.snapshot_contents[:last].string).to match(/\ACompiled data for \/.*\/foo.dat\z/)
+        expect(rep.snapshot_contents[:pre]).to be_nil
+        expect(rep.snapshot_contents[:post]).to be_nil
+      end
+    end
+
+    context 'normal flow with textual rep and text-to-binary filter' do
+      before do
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_started, rep, :whatever)
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_ended, rep, :whatever)
+
+        filter_class = Class.new(::Nanoc::Filter) do
+          type text: :binary
+
+          def run(content, _params = {})
+            File.write(output_filename, "Binary #{content}")
+          end
+        end
+
+        expect(Nanoc::Filter).to receive(:named).with(:whatever) { filter_class }
+      end
+
+      example do
+        executor.filter(:whatever)
+
+        expect(File.read(rep.snapshot_contents[:last].filename))
+          .to eq('Binary <%= "Donkey" %> Power')
+        expect(rep.snapshot_contents[:pre]).to be_nil
+        expect(rep.snapshot_contents[:post]).to be_nil
+      end
+    end
+
+    context 'non-existant filter' do
+      it 'raises' do
+        expect { executor.filter(:ajlsdfjklaskldfj) }
+          .to raise_error(Nanoc::Int::Errors::UnknownFilter)
+      end
+    end
+
+    context 'non-binary rep, binary-to-something filter' do
+      before do
+        filter_class = Class.new(::Nanoc::Filter) do
+          type :binary
+
+          def run(_content, _params = {}); end
+        end
+
+        expect(Nanoc::Filter).to receive(:named).with(:whatever) { filter_class }
+      end
+
+      it 'raises' do
+        expect { executor.filter(:whatever) }
+          .to raise_error(Nanoc::Int::Errors::CannotUseBinaryFilter)
+      end
+    end
+
+    context 'binary rep, text-to-something filter' do
+      let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('foo.md')) }
+
+      it 'raises' do
+        expect { executor.filter(:erb) }
+          .to raise_error(Nanoc::Int::Errors::CannotUseTextualFilter)
+      end
+    end
+
+    context 'binary filter that does not write anything' do
+      let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('foo.dat')) }
+
+      before do
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_started, rep, :whatever)
+        expect(Nanoc::Int::NotificationCenter)
+          .to receive(:post).with(:filtering_ended, rep, :whatever)
+
+        File.write(content.filename, 'Foo Data')
+
+        filter_class = Class.new(::Nanoc::Filter) do
+          type :binary
+
+          def run(_filename, _params = {}); end
+        end
+
+        expect(Nanoc::Filter).to receive(:named).with(:whatever) { filter_class }
+      end
+
+      example do
+        expect { executor.filter(:whatever) }
+          .to raise_error(Nanoc::Int::Executor::OutputNotWrittenError)
+      end
+    end
+
+    context 'content is frozen' do
+      let(:item) do
+        Nanoc::Int::Item.new('foo bar', {}, '/foo.md').tap(&:freeze)
+      end
+
+      let(:filter_that_modifies_content) do
+        Class.new(::Nanoc::Filter) do
+          def run(content, _params = {})
+            content.gsub!('foo', 'moo')
+            content
+          end
+        end
+      end
+
+      let(:filter_that_modifies_params) do
+        Class.new(::Nanoc::Filter) do
+          def run(_content, params = {})
+            params[:foo] = 'bar'
+            'asdf'
+          end
+        end
+      end
+
+      it 'errors when attempting to modify content' do
+        expect(Nanoc::Filter).to receive(:named).with(:whatever).and_return(filter_that_modifies_content)
+        expect { executor.filter(:whatever) }.to raise_frozen_error
+      end
+
+      it 'receives frozen filter args' do
+        expect(Nanoc::Filter).to receive(:named).with(:whatever).and_return(filter_that_modifies_params)
+        expect { executor.filter(:whatever) }.to raise_frozen_error
+      end
+    end
+  end
+
+  describe '#layout' do
+    let(:site) { double(:site, config: config, layouts: layouts) }
+
+    let(:config) do
+      {
+        string_pattern_type: 'glob',
+      }
+    end
+
+    let(:layout) do
+      Nanoc::Int::Layout.new(layout_content, { bug: 'Gum Emperor' }, '/default.erb')
+    end
+
+    let(:layouts) { [layout] }
+
+    let(:layout_content) { 'head <%= @foo %> foot' }
+
+    let(:assigns) do
+      { foo: 'hallo' }
+    end
+
+    let(:view_context) do
+      Nanoc::ViewContext.new(
+        reps: double(:reps),
+        items: double(:items),
+        dependency_tracker: dependency_tracker,
+        compilation_context: double(:compilation_context),
+      )
+    end
+
+    let(:rule_memory) do
+      Nanoc::Int::RuleMemory.new(rep).tap do |mem|
+        mem.add_filter(:erb, {})
+      end
+    end
+
+    before do
+      rep.snapshot_defs = [Nanoc::Int::SnapshotDef.new(:pre)]
+
+      allow(compilation_context).to receive(:site) { site }
+      allow(compilation_context).to receive(:assigns_for).with(rep, dependency_tracker) { assigns }
+      allow(compilation_context).to receive(:create_view_context).with(dependency_tracker).and_return(view_context)
+
+      allow(action_provider).to receive(:memory_for).with(layout).and_return(rule_memory)
+    end
+
+    subject { executor.layout('/default.*') }
+
+    context 'accessing layout attributes' do
+      let(:layout_content) { 'head <%= @layout[:bug] %> foot' }
+
+      it 'exposes @layout as view' do
+        allow(dependency_tracker).to receive(:enter)
+          .with(layout, raw_content: true, attributes: false, compiled_content: false, path: false)
+        allow(dependency_tracker).to receive(:enter)
+          .with(layout, raw_content: false, attributes: true, compiled_content: false, path: false)
+        allow(dependency_tracker).to receive(:exit)
+        subject
+        expect(rep.snapshot_contents[:last].string).to eq('head Gum Emperor foot')
+      end
+    end
+
+    context 'normal flow' do
+      it 'updates last content' do
+        subject
+        expect(rep.snapshot_contents[:last].string).to eq('head hallo foot')
+      end
+
+      it 'sets frozen content' do
+        subject
+        expect(rep.snapshot_contents[:last]).to be_frozen
+        expect(rep.snapshot_contents[:pre]).to be_frozen
+      end
+
+      it 'does not create pre snapshot' do
+        # a #layout is followed by a #snapshot(:pre, …)
+        expect(rep.snapshot_contents[:pre]).to be_nil
+        subject
+        expect(rep.snapshot_contents[:pre]).to be_nil
+      end
+
+      it 'sends notifications' do
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_started, rep, :erb).ordered
+        expect(Nanoc::Int::NotificationCenter).to receive(:post).with(:filtering_ended, rep, :erb).ordered
+
+        subject
+      end
+
+      context 'compiled_content reference in layout' do
+        let(:layout_content) { 'head <%= @item_rep.compiled_content(snapshot: :pre) %> foot' }
+
+        let(:assigns) do
+          { item_rep: Nanoc::ItemRepView.new(rep, view_context) }
+        end
+
+        before do
+          executor.snapshot(:pre)
+        end
+
+        it 'can contain compiled_content reference' do
+          subject
+          expect(rep.snapshot_contents[:last].string).to eq('head Donkey Power foot')
+        end
+      end
+
+      context 'content with layout reference' do
+        let(:layout_content) { 'head <%= @layout.identifier %> foot' }
+
+        it 'includes layout in assigns' do
+          subject
+          expect(rep.snapshot_contents[:last].string).to eq('head /default.erb foot')
+        end
+      end
+    end
+
+    context 'no layout found' do
+      let(:layouts) do
+        [Nanoc::Int::Layout.new('head <%= @foo %> foot', {}, '/other.erb')]
+      end
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::UnknownLayout)
+      end
+    end
+
+    context 'no filter specified' do
+      let(:rule_memory) do
+        Nanoc::Int::RuleMemory.new(rep)
+      end
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::UndefinedFilterForLayout)
+      end
+    end
+
+    context 'binary item' do
+      let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('donkey.md')) }
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::CannotLayoutBinaryItem)
+      end
+    end
+
+    it 'receives frozen filter args' do
+      filter_class = Class.new(::Nanoc::Filter) do
+        def run(_content, params = {})
+          params[:foo] = 'bar'
+          'asdf'
+        end
+      end
+
+      expect(Nanoc::Filter).to receive(:named).with(:erb) { filter_class }
+
+      expect { subject }.to raise_frozen_error
+    end
+  end
+
+  describe '#snapshot' do
+    context 'binary content' do
+      let(:content) { Nanoc::Int::BinaryContent.new(File.expand_path('donkey.dat')) }
+
+      it 'creates snapshots' do
+        executor.snapshot(:something)
+
+        expect(rep.snapshot_contents[:something]).not_to be_nil
+      end
+    end
+
+    context 'textual content' do
+      let(:content) { Nanoc::Int::TextualContent.new('Donkey Power') }
+
+      it 'creates a snapshot' do
+        executor.snapshot(:something)
+
+        expect(rep.snapshot_contents[:something].string).to eq('Donkey Power')
+      end
+    end
+
+    context 'final snapshot' do
+      let(:content) { Nanoc::Int::TextualContent.new('Donkey Power') }
+
+      context 'raw path' do
+        before do
+          rep.raw_paths = { something: 'output/donkey.md' }
+        end
+
+        it 'does not write' do
+          executor.snapshot(:something)
+
+          expect(File.file?('output/donkey.md')).not_to be
+        end
+      end
+
+      context 'no raw path' do
+        it 'does not write' do
+          executor.snapshot(:something)
+
+          expect(File.file?('output/donkey.md')).to eq(false)
+        end
+      end
+    end
+  end
+
+  describe '#find_layout' do
+    let(:site) { double(:site, config: config, layouts: layouts) }
+
+    let(:config) { {} }
+
+    before do
+      allow(compilation_context).to receive(:site) { site }
+    end
+
+    subject { executor.find_layout(arg) }
+
+    context 'layout with cleaned identifier exists' do
+      let(:arg) { '/default' }
+
+      let(:layouts) do
+        [Nanoc::Int::Layout.new('head <%= @foo %> foot', {}, '/default/')]
+      end
+
+      it { is_expected.to eq(layouts[0]) }
+    end
+
+    context 'no layout with cleaned identifier exists' do
+      let(:layouts) do
+        [Nanoc::Int::Layout.new('head <%= @foo %> foot', {}, '/default.erb')]
+      end
+
+      context 'globs' do
+        let(:config) { { string_pattern_type: 'glob' } }
+
+        let(:arg) { '/default.*' }
+
+        it { is_expected.to eq(layouts[0]) }
+      end
+
+      context 'no globs' do
+        let(:config) { { string_pattern_type: 'legacy' } }
+
+        let(:arg) { '/default.*' }
+
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::UnknownLayout)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/item_rep_router_spec.rb b/spec/nanoc/base/services/item_rep_router_spec.rb
new file mode 100644
index 0000000..4eaac58
--- /dev/null
+++ b/spec/nanoc/base/services/item_rep_router_spec.rb
@@ -0,0 +1,134 @@
+describe(Nanoc::Int::ItemRepRouter) do
+  subject(:item_rep_router) { described_class.new(reps, action_provider, site) }
+
+  let(:reps) { double(:reps) }
+  let(:action_provider) { double(:action_provider) }
+  let(:site) { double(:site, config: config) }
+  let(:config) { Nanoc::Int::Configuration.new.with_defaults }
+
+  describe '#run' do
+    subject { item_rep_router.run }
+
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/foo.md') }
+
+    let(:reps) do
+      [
+        Nanoc::Int::ItemRep.new(item, :default),
+        Nanoc::Int::ItemRep.new(item, :csv),
+      ]
+    end
+
+    let(:paths_0) do
+      { last: '/foo/index.html' }
+    end
+
+    let(:paths_1) do
+      { last: '/bar.html' }
+    end
+
+    example do
+      expect(action_provider).to receive(:paths_for).with(reps[0]).and_return(paths_0)
+      expect(action_provider).to receive(:paths_for).with(reps[1]).and_return(paths_1)
+
+      subject
+
+      expect(reps[0].raw_paths).to eql(last: 'output/foo/index.html')
+      expect(reps[0].paths).to eql(last: '/foo/')
+
+      expect(reps[1].raw_paths).to eql(last: 'output/bar.html')
+      expect(reps[1].paths).to eql(last: '/bar.html')
+    end
+  end
+
+  describe '#route_rep' do
+    subject { item_rep_router.route_rep(rep, path, snapshot_name, paths_to_reps) }
+
+    let(:path) { basic_path }
+    let(:snapshot_name) { :foo }
+    let(:rep) { Nanoc::Int::ItemRep.new(item, :default) }
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/foo.md') }
+    let(:paths_to_reps) { {} }
+
+    context 'basic path is nil' do
+      let(:basic_path) { nil }
+      it { is_expected.to be_nil }
+    end
+
+    context 'basic path is not nil' do
+      let(:basic_path) { '/foo/index.html' }
+
+      context 'other snapshot with this path already exists' do
+        let(:paths_to_reps) { { '/foo/index.html' => double(:other_rep) } }
+
+        it 'errors' do
+          expect { subject }.to raise_error(Nanoc::Int::ItemRepRouter::IdenticalRoutesError)
+        end
+      end
+
+      context 'path is unique' do
+        it 'sets the raw path' do
+          subject
+          expect(rep.raw_paths).to eql(foo: 'output/foo/index.html')
+        end
+
+        it 'sets the path' do
+          subject
+          expect(rep.paths).to eql(foo: '/foo/')
+        end
+
+        it 'adds to paths_to_reps' do
+          subject
+          expect(paths_to_reps).to have_key('/foo/index.html')
+        end
+
+        context 'path does not start with a slash' do
+          let(:basic_path) { 'foo/index.html' }
+
+          it 'errors' do
+            expect { subject }.to raise_error(Nanoc::Int::ItemRepRouter::RouteWithoutSlashError)
+          end
+        end
+
+        context 'path is not UTF-8' do
+          let(:basic_path) { '/foo/index.html'.encode('ISO-8859-1') }
+
+          it 'sets the path as UTF-8' do
+            subject
+            expect(rep.paths).to eql(foo: '/foo/')
+            expect(rep.paths[:foo].encoding.to_s).to eql('UTF-8')
+          end
+
+          it 'sets the raw path as UTF-8' do
+            subject
+            expect(rep.raw_paths).to eql(foo: 'output/foo/index.html')
+            expect(rep.raw_paths[:foo].encoding.to_s).to eql('UTF-8')
+          end
+        end
+      end
+    end
+  end
+
+  describe '#strip_index_filename' do
+    subject { item_rep_router.strip_index_filename(basic_path) }
+
+    context 'basic path ends with /index.html' do
+      let(:basic_path) { '/bar/index.html' }
+      it { is_expected.to eql('/bar/') }
+    end
+
+    context 'basic path contains /index.html' do
+      let(:basic_path) { '/bar/index.html/foo' }
+      it { is_expected.to eql('/bar/index.html/foo') }
+    end
+
+    context 'basic path ends with xindex.html' do
+      let(:basic_path) { '/bar/xindex.html' }
+      it { is_expected.to eql('/bar/xindex.html') }
+    end
+
+    context 'basic path does not contain /index.html' do
+      let(:basic_path) { '/bar/foo.html' }
+      it { is_expected.to eql('/bar/foo.html') }
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/item_rep_selector_spec.rb b/spec/nanoc/base/services/item_rep_selector_spec.rb
new file mode 100644
index 0000000..46c73fe
--- /dev/null
+++ b/spec/nanoc/base/services/item_rep_selector_spec.rb
@@ -0,0 +1,169 @@
+describe Nanoc::Int::ItemRepSelector do
+  let(:selector) { described_class.new(reps_for_selector) }
+
+  let(:item) do
+    Nanoc::Int::Item.new('stuff', {}, '/foo.md')
+  end
+
+  let(:reps_array) do
+    [
+      Nanoc::Int::ItemRep.new(item, :a),
+      Nanoc::Int::ItemRep.new(item, :b),
+      Nanoc::Int::ItemRep.new(item, :c),
+      Nanoc::Int::ItemRep.new(item, :d),
+      Nanoc::Int::ItemRep.new(item, :e),
+    ]
+  end
+
+  let(:reps_for_selector) { reps_array }
+
+  let(:names_to_reps) do
+    reps_array.each_with_object({}) do |rep, acc|
+      acc[rep.name] = rep
+    end
+  end
+
+  let(:dependencies) { {} }
+
+  let(:result) do
+    tentatively_yielded = []
+    successfully_yielded = []
+    selector.each do |rep|
+      tentatively_yielded << rep.name
+
+      dependencies.fetch(rep.name, []).each do |name|
+        unless successfully_yielded.include?(name)
+          raise Nanoc::Int::Errors::UnmetDependency.new(names_to_reps[name])
+        end
+      end
+
+      successfully_yielded << rep.name
+    end
+
+    [tentatively_yielded, successfully_yielded]
+  end
+
+  let(:tentatively_yielded) { result[0] }
+  let(:successfully_yielded) { result[1] }
+
+  describe 'error' do
+    context 'plain error' do
+      subject { selector.each { |_rep| raise 'heh' } }
+
+      it 'raises' do
+        expect { subject }.to raise_error(RuntimeError, 'heh')
+      end
+    end
+
+    context 'plain dependency error' do
+      subject do
+        idx = 0
+        selector.each do |_rep|
+          idx += 1
+
+          raise Nanoc::Int::Errors::UnmetDependency.new(reps_array[2]) if idx == 1
+        end
+      end
+
+      it 'does not raise' do
+        expect { subject }.not_to raise_error
+      end
+    end
+
+    context 'wrapped error' do
+      subject do
+        selector.each do |rep|
+          begin
+            raise 'heh'
+          rescue => e
+            raise Nanoc::Int::Errors::CompilationError.new(e, rep)
+          end
+        end
+      end
+
+      it 'raises original error' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::CompilationError) do |err|
+          expect(err.unwrap).to be_a(RuntimeError)
+          expect(err.unwrap.message).to eq('heh')
+        end
+      end
+    end
+
+    context 'wrapped dependency error' do
+      subject do
+        idx = 0
+        selector.each do |rep|
+          idx += 1
+
+          begin
+            raise Nanoc::Int::Errors::UnmetDependency.new(reps_array[2]) if idx == 1
+          rescue => e
+            raise Nanoc::Int::Errors::CompilationError.new(e, rep)
+          end
+        end
+      end
+
+      it 'does not raise' do
+        expect { subject }.not_to raise_error
+      end
+    end
+  end
+
+  describe 'yield order' do
+    context 'linear dependencies' do
+      let(:dependencies) do
+        {
+          a: [:b],
+          b: [:c],
+          c: [:d],
+          d: [:e],
+          e: [],
+        }
+      end
+
+      example do
+        expect(successfully_yielded).to eq [:e, :d, :c, :b, :a]
+        expect(tentatively_yielded).to eq [:a, :b, :c, :d, :e, :d, :c, :b, :a]
+      end
+    end
+
+    context 'no dependencies' do
+      let(:dependencies) do
+        {}
+      end
+
+      example do
+        expect(successfully_yielded).to eq [:a, :b, :c, :d, :e]
+        expect(tentatively_yielded).to eq [:a, :b, :c, :d, :e]
+      end
+    end
+
+    context 'star dependencies' do
+      let(:dependencies) do
+        {
+          a: [:b, :c, :d, :e],
+        }
+      end
+
+      example do
+        expect(successfully_yielded).to eq [:b, :c, :d, :e, :a]
+        expect(tentatively_yielded).to eq [:a, :b, :c, :d, :e, :a]
+      end
+    end
+
+    context 'star dependencies; selectively recompiling' do
+      let(:reps_for_selector) { reps_array.first(1) }
+
+      let(:dependencies) do
+        {
+          a: [:b, :c, :d, :e],
+        }
+      end
+
+      example do
+        expect(successfully_yielded).to eq [:b, :c, :d, :e, :a]
+        expect(tentatively_yielded).to eq [:a, :b, :a, :c, :a, :d, :a, :e, :a]
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/outdatedness_checker_spec.rb b/spec/nanoc/base/services/outdatedness_checker_spec.rb
new file mode 100644
index 0000000..6eeb7ad
--- /dev/null
+++ b/spec/nanoc/base/services/outdatedness_checker_spec.rb
@@ -0,0 +1,370 @@
+describe Nanoc::Int::OutdatednessChecker do
+  let(:outdatedness_checker) do
+    described_class.new(
+      site: site,
+      checksum_store: checksum_store,
+      dependency_store: dependency_store,
+      rule_memory_store: rule_memory_store,
+      action_provider: action_provider,
+      reps: reps,
+    )
+  end
+
+  let(:site) { double(:site) }
+  let(:checksum_store) { double(:checksum_store) }
+  let(:dependency_store) { double(:dependency_store) }
+
+  let(:rule_memory_store) do
+    Nanoc::Int::RuleMemoryStore.new
+  end
+
+  let(:old_memory_for_item_rep) do
+    Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+      mem.add_filter(:erb, {})
+    end
+  end
+
+  let(:new_memory_for_item_rep) { old_memory_for_item_rep }
+
+  let(:action_provider) { double(:action_provider) }
+
+  let(:reps) do
+    Nanoc::Int::ItemRepRepo.new
+  end
+
+  let(:item_rep) { Nanoc::Int::ItemRep.new(item, :default) }
+  let(:item) { Nanoc::Int::Item.new('stuff', {}, '/foo.md') }
+
+  let(:objects) { [item] }
+
+  before do
+    reps << item_rep
+    rule_memory_store[item_rep] = old_memory_for_item_rep.serialize
+
+    allow(action_provider).to receive(:memory_for).with(item_rep).and_return(new_memory_for_item_rep)
+  end
+
+  describe '#basic_outdatedness_reason_for' do
+    subject { outdatedness_checker.send(:basic_outdatedness_reason_for, obj) }
+
+    let(:checksum_store) { Nanoc::Int::ChecksumStore.new(objects: objects) }
+
+    let(:config) { Nanoc::Int::Configuration.new }
+
+    before do
+      checksum_store.add(item)
+
+      allow(site).to receive(:code_snippets).and_return([])
+      allow(site).to receive(:config).and_return(config)
+    end
+
+    context 'with item' do
+      let(:obj) { item }
+
+      context 'rule memory differs' do
+        let(:new_memory_for_item_rep) do
+          Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+            mem.add_filter(:super_erb, {})
+          end
+        end
+
+        it 'is outdated due to rule differences' do
+          expect(subject).to eql(Nanoc::Int::OutdatednessReasons::RulesModified)
+        end
+      end
+
+      # …
+    end
+
+    context 'with item rep' do
+      let(:obj) { item_rep }
+
+      context 'rule memory differs' do
+        let(:new_memory_for_item_rep) do
+          Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+            mem.add_filter(:super_erb, {})
+          end
+        end
+
+        it 'is outdated due to rule differences' do
+          expect(subject).to eql(Nanoc::Int::OutdatednessReasons::RulesModified)
+        end
+      end
+
+      # …
+    end
+
+    context 'with layout' do
+      # …
+    end
+  end
+
+  describe '#outdated_due_to_dependencies?' do
+    subject { outdatedness_checker.send(:outdated_due_to_dependencies?, item) }
+
+    let(:dependency_store) do
+      Nanoc::Int::DependencyStore.new(objects)
+    end
+
+    let(:checksum_store) { Nanoc::Int::ChecksumStore.new(objects: objects) }
+
+    let(:other_item) { Nanoc::Int::Item.new('other stuff', {}, '/other.md') }
+    let(:other_item_rep) { Nanoc::Int::ItemRep.new(other_item, :default) }
+
+    let(:config) { Nanoc::Int::Configuration.new }
+
+    let(:objects) { [item, other_item] }
+
+    let(:old_memory_for_other_item_rep) do
+      Nanoc::Int::RuleMemory.new(other_item_rep).tap do |mem|
+        mem.add_filter(:erb, {})
+      end
+    end
+
+    let(:new_memory_for_other_item_rep) { old_memory_for_other_item_rep }
+
+    before do
+      reps << other_item_rep
+      rule_memory_store[other_item_rep] = old_memory_for_other_item_rep.serialize
+      checksum_store.add(item)
+      checksum_store.add(other_item)
+      checksum_store.add(config)
+
+      allow(action_provider).to receive(:memory_for).with(other_item_rep).and_return(new_memory_for_other_item_rep)
+      allow(site).to receive(:code_snippets).and_return([])
+      allow(site).to receive(:config).and_return(config)
+    end
+
+    context 'transitive dependency' do
+      let(:distant_item) { Nanoc::Int::Item.new('distant stuff', {}, '/distant.md') }
+      let(:distant_item_rep) { Nanoc::Int::ItemRep.new(distant_item, :default) }
+
+      before do
+        reps << distant_item_rep
+        checksum_store.add(distant_item)
+        rule_memory_store[distant_item_rep] = old_memory_for_other_item_rep.serialize
+        allow(action_provider).to receive(:memory_for).with(distant_item_rep).and_return(new_memory_for_other_item_rep)
+      end
+
+      context 'on attribute + attribute' do
+        before do
+          dependency_store.record_dependency(item, other_item, attributes: true)
+          dependency_store.record_dependency(other_item, distant_item, attributes: true)
+        end
+
+        context 'distant attribute changed' do
+          before { distant_item.attributes[:title] = 'omg new title' }
+
+          it 'has correct outdatedness of item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, item)).not_to be
+          end
+
+          it 'has correct outdatedness of other item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, other_item)).to be
+          end
+        end
+
+        context 'distant raw content changed' do
+          before { distant_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+
+          it 'has correct outdatedness of item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, item)).not_to be
+          end
+
+          it 'has correct outdatedness of other item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, other_item)).not_to be
+          end
+        end
+      end
+
+      context 'on compiled content + attribute' do
+        before do
+          dependency_store.record_dependency(item, other_item, compiled_content: true)
+          dependency_store.record_dependency(other_item, distant_item, attributes: true)
+        end
+
+        context 'distant attribute changed' do
+          before { distant_item.attributes[:title] = 'omg new title' }
+
+          it 'has correct outdatedness of item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, item)).to be
+          end
+
+          it 'has correct outdatedness of other item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, other_item)).to be
+          end
+        end
+
+        context 'distant raw content changed' do
+          before { distant_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+
+          it 'has correct outdatedness of item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, item)).not_to be
+          end
+
+          it 'has correct outdatedness of other item' do
+            expect(outdatedness_checker.send(:outdated_due_to_dependencies?, other_item)).not_to be
+          end
+        end
+      end
+    end
+
+    context 'only attribute dependency' do
+      before do
+        dependency_store.record_dependency(item, other_item, attributes: true)
+      end
+
+      context 'attribute changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        it { is_expected.to be }
+      end
+
+      context 'raw content changed' do
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.not_to be }
+      end
+
+      context 'attribute + raw content changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.to be }
+      end
+
+      context 'path changed' do
+        let(:new_memory_for_other_item_rep) do
+          Nanoc::Int::RuleMemory.new(other_item_rep).tap do |mem|
+            mem.add_filter(:erb, {})
+            mem.add_snapshot(:donkey, '/giraffe.txt')
+          end
+        end
+
+        it { is_expected.not_to be }
+      end
+    end
+
+    context 'only raw content dependency' do
+      before do
+        dependency_store.record_dependency(item, other_item, raw_content: true)
+      end
+
+      context 'attribute changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        it { is_expected.not_to be }
+      end
+
+      context 'raw content changed' do
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.to be }
+      end
+
+      context 'attribute + raw content changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.to be }
+      end
+
+      context 'path changed' do
+        let(:new_memory_for_other_item_rep) do
+          Nanoc::Int::RuleMemory.new(other_item_rep).tap do |mem|
+            mem.add_filter(:erb, {})
+            mem.add_snapshot(:donkey, '/giraffe.txt')
+          end
+        end
+
+        it { is_expected.not_to be }
+      end
+    end
+
+    context 'only path dependency' do
+      before do
+        dependency_store.record_dependency(item, other_item, raw_content: true)
+      end
+
+      context 'attribute changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        it { is_expected.not_to be }
+      end
+
+      context 'raw content changed' do
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.to be }
+      end
+
+      context 'path changed' do
+        let(:new_memory_for_other_item_rep) do
+          Nanoc::Int::RuleMemory.new(other_item_rep).tap do |mem|
+            mem.add_filter(:erb, {})
+            mem.add_snapshot(:donkey, '/giraffe.txt')
+          end
+        end
+
+        it { is_expected.not_to be }
+      end
+    end
+
+    context 'attribute + raw content dependency' do
+      before do
+        dependency_store.record_dependency(item, other_item, attributes: true, raw_content: true)
+      end
+
+      context 'attribute changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        it { is_expected.to be }
+      end
+
+      context 'raw content changed' do
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.to be }
+      end
+
+      context 'attribute + raw content changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.to be }
+      end
+
+      context 'rules changed' do
+        let(:new_memory_for_other_item_rep) do
+          Nanoc::Int::RuleMemory.new(other_item_rep).tap do |mem|
+            mem.add_filter(:erb, {})
+            mem.add_filter(:donkey, {})
+          end
+        end
+
+        it { is_expected.not_to be }
+      end
+    end
+
+    context 'attribute + other dependency' do
+      before do
+        dependency_store.record_dependency(item, other_item, attributes: true, path: true)
+      end
+
+      context 'attribute changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        it { is_expected.to be }
+      end
+
+      context 'raw content changed' do
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.not_to be }
+      end
+    end
+
+    context 'other dependency' do
+      before do
+        dependency_store.record_dependency(item, other_item, path: true)
+      end
+
+      context 'attribute changed' do
+        before { other_item.attributes[:title] = 'omg new title' }
+        it { is_expected.not_to be }
+      end
+
+      context 'raw content changed' do
+        before { other_item.content = Nanoc::Int::TextualContent.new('omg new content') }
+        it { is_expected.not_to be }
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/outdatedness_rules_spec.rb b/spec/nanoc/base/services/outdatedness_rules_spec.rb
new file mode 100644
index 0000000..1888cfd
--- /dev/null
+++ b/spec/nanoc/base/services/outdatedness_rules_spec.rb
@@ -0,0 +1,432 @@
+describe Nanoc::Int::OutdatednessRules do
+  describe '#apply' do
+    subject { rule_class.instance.apply(obj, outdatedness_checker) }
+
+    let(:obj) { item_rep }
+
+    let(:outdatedness_checker) do
+      Nanoc::Int::OutdatednessChecker.new(
+        site: site,
+        checksum_store: checksum_store,
+        dependency_store: dependency_store,
+        rule_memory_store: rule_memory_store,
+        action_provider: action_provider,
+        reps: reps,
+      )
+    end
+
+    let(:item_rep) { Nanoc::Int::ItemRep.new(item, :default) }
+    let(:item) { Nanoc::Int::Item.new('stuff', {}, '/foo.md') }
+
+    let(:site) { double(:site) }
+    let(:config) { Nanoc::Int::Configuration.new }
+    let(:code_snippets) { [] }
+    let(:objects) { [config] + code_snippets + [item] }
+
+    let(:action_provider) { double(:action_provider) }
+    let(:reps) { Nanoc::Int::ItemRepRepo.new }
+    let(:dependency_store) { Nanoc::Int::DependencyStore.new(dependency_store_objects) }
+    let(:rule_memory_store) { Nanoc::Int::RuleMemoryStore.new }
+    let(:checksum_store) { Nanoc::Int::ChecksumStore.new(objects: objects) }
+
+    let(:dependency_store_objects) { [item] }
+
+    before do
+      allow(site).to receive(:code_snippets).and_return(code_snippets)
+      allow(site).to receive(:config).and_return(config)
+    end
+
+    context 'CodeSnippetsModified' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::CodeSnippetsModified }
+
+      context 'no snippets' do
+        let(:code_snippets) { [] }
+        it { is_expected.not_to be }
+      end
+
+      context 'only non-outdated snippets' do
+        let(:code_snippet) { Nanoc::Int::CodeSnippet.new('asdf', 'lib/foo.md') }
+        let(:code_snippets) { [code_snippet] }
+
+        before { checksum_store.add(code_snippet) }
+
+        it { is_expected.not_to be }
+      end
+
+      context 'only non-outdated snippets' do
+        let(:code_snippet) { Nanoc::Int::CodeSnippet.new('asdf', 'lib/foo.md') }
+        let(:code_snippet_old) { Nanoc::Int::CodeSnippet.new('aaaaaaaa', 'lib/foo.md') }
+        let(:code_snippets) { [code_snippet] }
+
+        before { checksum_store.add(code_snippet_old) }
+
+        it { is_expected.to be }
+      end
+    end
+
+    context 'ConfigurationModified' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::ConfigurationModified }
+
+      context 'only non-outdated snippets' do
+        let(:config) { Nanoc::Int::CodeSnippet.new('asdf', 'lib/foo.md') }
+
+        before { checksum_store.add(config) }
+
+        it { is_expected.not_to be }
+      end
+
+      context 'only non-outdated snippets' do
+        let(:config) { Nanoc::Int::Configuration.new }
+        let(:config_old) { Nanoc::Int::Configuration.new(hash: { foo: 125 }) }
+
+        before { checksum_store.add(config_old) }
+
+        it { is_expected.to be }
+      end
+    end
+
+    context 'NotWritten' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::NotWritten }
+
+      context 'no path' do
+        before { item_rep.paths = {} }
+
+        it { is_expected.not_to be }
+      end
+
+      context 'path' do
+        let(:path) { 'foo.txt' }
+
+        before { item_rep.raw_paths = { last: path } }
+
+        context 'not written' do
+          it { is_expected.to be }
+        end
+
+        context 'written' do
+          before { File.write(path, 'hello') }
+          it { is_expected.not_to be }
+        end
+      end
+    end
+
+    context 'ContentModified' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::ContentModified }
+
+      context 'item' do
+        let(:obj) { item }
+
+        before { reps << item_rep }
+
+        context 'no checksum available' do
+          it { is_expected.to be }
+        end
+
+        context 'checksum available and same' do
+          before { checksum_store.add(item) }
+          it { is_expected.not_to be }
+        end
+
+        context 'checksum available, but content different' do
+          let(:old_item) { Nanoc::Int::Item.new('other stuff!!!!', {}, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.to be }
+        end
+
+        context 'checksum available, but attributes different' do
+          let(:old_item) { Nanoc::Int::Item.new('stuff', { greeting: 'hi' }, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.not_to be }
+        end
+      end
+
+      context 'item rep' do
+        let(:obj) { item_rep }
+
+        context 'no checksum available' do
+          it { is_expected.to be }
+        end
+
+        context 'checksum available and same' do
+          before { checksum_store.add(item) }
+          it { is_expected.not_to be }
+        end
+
+        context 'checksum available, but content different' do
+          let(:old_item) { Nanoc::Int::Item.new('other stuff!!!!', {}, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.to be }
+        end
+
+        context 'checksum available, but attributes different' do
+          let(:old_item) { Nanoc::Int::Item.new('stuff', { greeting: 'hi' }, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.not_to be }
+        end
+      end
+    end
+
+    context 'AttributesModified' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::AttributesModified }
+
+      context 'item' do
+        let(:obj) { item }
+
+        before { reps << item_rep }
+
+        context 'no checksum available' do
+          it { is_expected.to be }
+        end
+
+        context 'checksum available and same' do
+          before { checksum_store.add(item) }
+          it { is_expected.not_to be }
+        end
+
+        context 'checksum available, but content different' do
+          let(:old_item) { Nanoc::Int::Item.new('other stuff!!!!', {}, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.not_to be }
+        end
+
+        context 'checksum available, but attributes different' do
+          let(:old_item) { Nanoc::Int::Item.new('stuff', { greeting: 'hi' }, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.to be }
+        end
+      end
+
+      context 'item rep' do
+        let(:obj) { item_rep }
+
+        context 'no checksum available' do
+          it { is_expected.to be }
+        end
+
+        context 'checksum available and same' do
+          before { checksum_store.add(item) }
+          it { is_expected.not_to be }
+        end
+
+        context 'checksum available, but content different' do
+          let(:old_item) { Nanoc::Int::Item.new('other stuff!!!!', {}, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.not_to be }
+        end
+
+        context 'checksum available, but attributes different' do
+          let(:old_item) { Nanoc::Int::Item.new('stuff', { greeting: 'hi' }, '/foo.md') }
+          before { checksum_store.add(old_item) }
+          it { is_expected.to be }
+        end
+      end
+    end
+
+    context 'RulesModified' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::RulesModified }
+
+      let(:old_mem) do
+        Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+          mem.add_filter(:erb, {})
+        end
+      end
+
+      before do
+        rule_memory_store[item_rep] = old_mem.serialize
+        allow(action_provider).to receive(:memory_for).with(item_rep).and_return(new_mem)
+      end
+
+      context 'memory is the same' do
+        let(:new_mem) { old_mem }
+        it { is_expected.not_to be }
+      end
+
+      context 'memory is different' do
+        let(:new_mem) do
+          Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+            mem.add_filter(:erb, {})
+            mem.add_filter(:donkey, {})
+          end
+        end
+
+        it { is_expected.to be }
+      end
+    end
+
+    context 'PathsModified' do
+      let(:rule_class) { Nanoc::Int::OutdatednessRules::PathsModified }
+
+      before do
+        allow(action_provider).to receive(:memory_for).with(item_rep).and_return(new_mem)
+      end
+
+      context 'old mem does not exist' do
+        let(:new_mem) do
+          Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+            mem.add_snapshot(:donkey, '/foo.md')
+            mem.add_filter(:asdf, {})
+          end
+        end
+
+        it { is_expected.to be }
+      end
+
+      context 'old mem exists' do
+        let(:old_mem) do
+          Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+            mem.add_filter(:erb, {})
+            mem.add_snapshot(:donkey, '/foo.md')
+          end
+        end
+
+        before do
+          rule_memory_store[item_rep] = old_mem.serialize
+        end
+
+        context 'paths in memory are the same' do
+          let(:new_mem) do
+            Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+              mem.add_snapshot(:donkey, '/foo.md')
+              mem.add_filter(:asdf, {})
+            end
+          end
+
+          it { is_expected.not_to be }
+        end
+
+        context 'paths in memory are different' do
+          let(:new_mem) do
+            Nanoc::Int::RuleMemory.new(item_rep).tap do |mem|
+              mem.add_filter(:erb, {})
+              mem.add_snapshot(:donkey, '/foo.md')
+              mem.add_filter(:donkey, {})
+              mem.add_snapshot(:giraffe, '/bar.md')
+            end
+          end
+
+          it { is_expected.to be }
+        end
+      end
+    end
+
+    describe '#{Content,Attributes}Modified' do
+      subject do
+        # TODO: remove negation
+        [
+          Nanoc::Int::OutdatednessRules::ContentModified,
+          Nanoc::Int::OutdatednessRules::AttributesModified,
+        ].map { |c| !c.instance.apply(new_obj, outdatedness_checker) }
+      end
+
+      let(:stored_obj) { raise 'override me' }
+      let(:new_obj)    { raise 'override me' }
+
+      shared_examples 'a document' do
+        let(:stored_obj) { klass.new('a', {}, '/foo.md') }
+        let(:new_obj)    { stored_obj }
+
+        context 'no checksum data' do
+          context 'not stored' do
+            it { is_expected.to eql([false, false]) }
+          end
+
+          context 'stored' do
+            before { checksum_store.add(stored_obj) }
+
+            context 'but content changed afterwards' do
+              let(:new_obj) { klass.new('aaaaaaaa', {}, '/foo.md') }
+              it { is_expected.to eql([false, true]) }
+            end
+
+            context 'but attributes changed afterwards' do
+              let(:new_obj) { klass.new('a', { animal: 'donkey' }, '/foo.md') }
+              it { is_expected.to eql([true, false]) }
+            end
+
+            context 'and unchanged' do
+              it { is_expected.to eql([true, true]) }
+            end
+          end
+        end
+
+        context 'checksum_data' do
+          let(:stored_obj) { klass.new('a', {}, '/foo.md', checksum_data: 'cs-data') }
+          let(:new_obj)    { stored_obj }
+
+          context 'not stored' do
+            it { is_expected.to eql([false, false]) }
+          end
+
+          context 'stored' do
+            before { checksum_store.add(stored_obj) }
+
+            context 'but checksum data afterwards' do
+              let(:new_obj) { klass.new('a', {}, '/foo.md', checksum_data: 'cs-data-new') }
+              it { is_expected.to eql([false, false]) }
+            end
+
+            context 'and unchanged' do
+              it { is_expected.to eql([true, true]) }
+            end
+          end
+        end
+
+        context 'content_checksum_data' do
+          let(:stored_obj) { klass.new('a', {}, '/foo.md', content_checksum_data: 'cs-data') }
+          let(:new_obj)    { stored_obj }
+
+          context 'not stored' do
+            it { is_expected.to eql([false, false]) }
+          end
+
+          context 'stored' do
+            before { checksum_store.add(stored_obj) }
+
+            context 'but checksum data afterwards' do
+              let(:new_obj) { klass.new('a', {}, '/foo.md', content_checksum_data: 'cs-data-new') }
+              it { is_expected.to eql([false, true]) }
+            end
+
+            context 'and unchanged' do
+              it { is_expected.to eql([true, true]) }
+            end
+          end
+        end
+
+        context 'attributes_checksum_data' do
+          let(:stored_obj) { klass.new('a', {}, '/foo.md', attributes_checksum_data: 'cs-data') }
+          let(:new_obj)    { stored_obj }
+
+          context 'not stored' do
+            it { is_expected.to eql([false, false]) }
+          end
+
+          context 'stored' do
+            before { checksum_store.add(stored_obj) }
+
+            context 'but checksum data afterwards' do
+              let(:new_obj) { klass.new('a', {}, '/foo.md', attributes_checksum_data: 'cs-data-new') }
+              it { is_expected.to eql([true, false]) }
+            end
+
+            context 'and unchanged' do
+              it { is_expected.to eql([true, true]) }
+            end
+          end
+        end
+      end
+
+      context 'item' do
+        let(:klass) { Nanoc::Int::Item }
+        it_behaves_like 'a document'
+      end
+
+      context 'layout' do
+        let(:klass) { Nanoc::Int::Layout }
+        it_behaves_like 'a document'
+      end
+
+      # …
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/pruner_spec.rb b/spec/nanoc/base/services/pruner_spec.rb
new file mode 100644
index 0000000..c86d38e
--- /dev/null
+++ b/spec/nanoc/base/services/pruner_spec.rb
@@ -0,0 +1,105 @@
+describe Nanoc::Pruner do
+  subject(:pruner) { described_class.new(config, reps, dry_run: dry_run, exclude: exclude) }
+
+  let(:config) { Nanoc::Int::Configuration.new({}) }
+  let(:dry_run) { false }
+  let(:exclude) { [] }
+
+  let(:reps) do
+    Nanoc::Int::ItemRepRepo.new.tap do |reps|
+      reps << Nanoc::Int::ItemRep.new(item, :default).tap do |rep|
+        rep.raw_paths = { last: 'output/asdf.html' }
+      end
+
+      reps << Nanoc::Int::ItemRep.new(item, :text).tap do |rep|
+        rep.raw_paths = { last: 'output/asdf.txt' }
+      end
+    end
+  end
+
+  let(:item) { Nanoc::Int::Item.new('asdf', {}, '/a.md') }
+
+  it 'is accessible through Nanoc::Extra::Pruner' do
+    expect(Nanoc::Extra::Pruner).to equal(Nanoc::Pruner)
+  end
+
+  describe '#files_and_dirs_in' do
+    subject { pruner.files_and_dirs_in('output/') }
+
+    before do
+      FileUtils.mkdir_p('output/projects')
+      FileUtils.mkdir_p('output/.git')
+
+      File.write('output/asdf.html', '<p>text</p>')
+      File.write('output/.htaccess', 'secret stuff here')
+      File.write('output/projects/nanoc.html', '<p>Nanoc is v cool!!</p>')
+      File.write('output/.git/HEAD', 'some content here')
+    end
+
+    context 'nothing excluded' do
+      let(:exclude) { [] }
+
+      it 'returns all files' do
+        files = [
+          'output/asdf.html',
+          'output/.htaccess',
+          'output/projects/nanoc.html',
+          'output/.git/HEAD',
+        ]
+        expect(subject[0]).to match_array(files)
+      end
+
+      it 'returns all directories' do
+        dirs = [
+          'output/',
+          'output/projects',
+          'output/.git',
+        ]
+        expect(subject[1]).to match_array(dirs)
+      end
+    end
+
+    context 'directory (.git) excluded' do
+      let(:exclude) { ['.git'] }
+
+      it 'returns all files' do
+        files = [
+          'output/asdf.html',
+          'output/.htaccess',
+          'output/projects/nanoc.html',
+        ]
+        expect(subject[0]).to match_array(files)
+      end
+
+      it 'returns all directories' do
+        dirs = [
+          'output/',
+          'output/projects',
+        ]
+        expect(subject[1]).to match_array(dirs)
+      end
+    end
+
+    context 'file (.htaccess) excluded' do
+      let(:exclude) { ['.htaccess'] }
+
+      it 'returns all files' do
+        files = [
+          'output/asdf.html',
+          'output/projects/nanoc.html',
+          'output/.git/HEAD',
+        ]
+        expect(subject[0]).to match_array(files)
+      end
+
+      it 'returns all directories' do
+        dirs = [
+          'output/',
+          'output/projects',
+          'output/.git',
+        ]
+        expect(subject[1]).to match_array(dirs)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/services/temp_filename_factory_spec.rb b/spec/nanoc/base/services/temp_filename_factory_spec.rb
new file mode 100644
index 0000000..6284584
--- /dev/null
+++ b/spec/nanoc/base/services/temp_filename_factory_spec.rb
@@ -0,0 +1,87 @@
+describe Nanoc::Int::TempFilenameFactory do
+  subject(:factory) { described_class.new }
+
+  let(:prefix) { 'foo' }
+
+  describe '#create' do
+    it 'creates unique paths' do
+      path_a = subject.create(prefix)
+      path_b = subject.create(prefix)
+
+      expect(path_a).not_to eq(path_b)
+    end
+
+    it 'returns absolute paths' do
+      path = subject.create(prefix)
+
+      expect(path).to match(/\A\//)
+    end
+
+    it 'creates the containing directory' do
+      expect(Dir[subject.root_dir + '/**/*']).to be_empty
+
+      path = subject.create(prefix)
+
+      expect(File.directory?(File.dirname(path))).to be(true)
+    end
+
+    it 'reuses the same path after cleanup' do
+      path_a = subject.create(prefix)
+
+      subject.cleanup(prefix)
+
+      path_b = subject.create(prefix)
+      expect(path_a).to eq(path_b)
+    end
+
+    it 'does not create the file' do
+      path = subject.create(prefix)
+      expect(File.file?(path)).not_to be(true)
+    end
+  end
+
+  describe '#cleanup' do
+    subject { factory.cleanup(prefix) }
+
+    let!(:path) { factory.create(prefix) }
+
+    before { File.write(path, 'hello') }
+
+    def files
+      Dir[factory.root_dir + '/**/*'].select { |fn| File.file?(fn) }
+    end
+
+    it 'removes generated files' do
+      expect { subject }.to change { files }.from([path]).to([])
+    end
+
+    context 'files with other prefixes exist' do
+      before do
+        factory.create('donkey')
+      end
+
+      it 'does not delete root dir' do
+        expect(File.directory?(factory.root_dir)).to be(true)
+        expect { subject }.not_to change { File.directory?(factory.root_dir) }
+      end
+    end
+
+    context 'no files with other prefixes exist' do
+      it 'deletes root dir' do
+        expect { subject }.to change { File.directory?(factory.root_dir) }.from(true).to(false)
+      end
+    end
+  end
+
+  describe 'other instance' do
+    let(:other_instance) do
+      Nanoc::Int::TempFilenameFactory.new
+    end
+
+    it 'creates unique paths across instances' do
+      path_a = subject.create(prefix)
+      path_b = other_instance.create(prefix)
+      expect(path_a).not_to eq(path_b)
+    end
+  end
+end
diff --git a/spec/nanoc/base/views/config_view_spec.rb b/spec/nanoc/base/views/config_view_spec.rb
new file mode 100644
index 0000000..bf2beea
--- /dev/null
+++ b/spec/nanoc/base/views/config_view_spec.rb
@@ -0,0 +1,96 @@
+describe Nanoc::ConfigView do
+  let(:config) do
+    Nanoc::Int::Configuration.new(hash: hash)
+  end
+
+  let(:hash) { { amount: 9000, animal: 'donkey' } }
+
+  let(:view) { described_class.new(config, nil) }
+
+  describe '#frozen?' do
+    subject { view.frozen? }
+
+    context 'non-frozen config' do
+      it { is_expected.to be(false) }
+    end
+
+    context 'frozen config' do
+      before { config.freeze }
+      it { is_expected.to be(true) }
+    end
+  end
+
+  describe '#[]' do
+    subject { view[key] }
+
+    context 'with existant key' do
+      let(:key) { :animal }
+      it { is_expected.to eql('donkey') }
+    end
+
+    context 'with non-existant key' do
+      let(:key) { :weapon }
+      it { is_expected.to eql(nil) }
+    end
+  end
+
+  describe '#fetch' do
+    context 'with existant key' do
+      let(:key) { :animal }
+
+      subject { view.fetch(key) }
+
+      it { is_expected.to eql('donkey') }
+    end
+
+    context 'with non-existant key' do
+      let(:key) { :weapon }
+
+      context 'with fallback' do
+        subject { view.fetch(key, 'nothing sorry') }
+        it { is_expected.to eql('nothing sorry') }
+      end
+
+      context 'with block' do
+        subject { view.fetch(key) { 'nothing sorry' } }
+        it { is_expected.to eql('nothing sorry') }
+      end
+
+      context 'with no fallback and no block' do
+        subject { view.fetch(key) }
+
+        it 'raises' do
+          expect { subject }.to raise_error(KeyError)
+        end
+      end
+    end
+  end
+
+  describe '#key?' do
+    subject { view.key?(key) }
+
+    context 'with existant key' do
+      let(:key) { :animal }
+      it { is_expected.to eql(true) }
+    end
+
+    context 'with non-existant key' do
+      let(:key) { :weapon }
+      it { is_expected.to eql(false) }
+    end
+  end
+
+  describe '#each' do
+    example do
+      res = []
+      view.each { |k, v| res << [k, v] }
+
+      expect(res).to eql([[:amount, 9000], [:animal, 'donkey']])
+    end
+  end
+
+  describe '#inspect' do
+    subject { view.inspect }
+    it { is_expected.to eql('<Nanoc::ConfigView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/document_view_spec.rb b/spec/nanoc/base/views/document_view_spec.rb
new file mode 100644
index 0000000..22c024f
--- /dev/null
+++ b/spec/nanoc/base/views/document_view_spec.rb
@@ -0,0 +1,332 @@
+shared_examples 'a document view' do
+  let(:view) { described_class.new(document, view_context) }
+
+  let(:view_context) do
+    Nanoc::ViewContext.new(
+      reps: double(:reps),
+      items: double(:items),
+      dependency_tracker: dependency_tracker,
+      compilation_context: double(:compilation_context),
+    )
+  end
+
+  let(:dependency_tracker) { Nanoc::Int::DependencyTracker.new(dependency_store) }
+  let(:dependency_store) { Nanoc::Int::DependencyStore.new([]) }
+  let(:base_item) { Nanoc::Int::Item.new('base', {}, '/base.md') }
+
+  before do
+    dependency_tracker.enter(base_item)
+  end
+
+  describe '#frozen?' do
+    let(:document) { entity_class.new('content', {}, '/asdf/') }
+
+    subject { view.frozen? }
+
+    context 'non-frozen document' do
+      it { is_expected.to be(false) }
+    end
+
+    context 'frozen document' do
+      before { document.freeze }
+      it { is_expected.to be(true) }
+    end
+  end
+
+  describe '#== and #eql?' do
+    let(:document) { entity_class.new('content', {}, '/asdf/') }
+
+    context 'comparing with document with same identifier' do
+      let(:other) { entity_class.new('content', {}, '/asdf/') }
+
+      it 'is ==' do
+        expect(view).to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with document with different identifier' do
+      let(:other) { entity_class.new('content', {}, '/fdsa/') }
+
+      it 'is not ==' do
+        expect(view).not_to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with document view with same identifier' do
+      let(:other) { other_view_class.new(entity_class.new('content', {}, '/asdf/'), nil) }
+
+      it 'is ==' do
+        expect(view).to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with document view with different identifier' do
+      let(:other) { other_view_class.new(entity_class.new('content', {}, '/fdsa/'), nil) }
+
+      it 'is not ==' do
+        expect(view).not_to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with other object' do
+      let(:other) { nil }
+
+      it 'is not ==' do
+        expect(view).not_to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+  end
+
+  describe '#[]' do
+    let(:document) { entity_class.new('stuff', { animal: 'donkey' }, '/foo/') }
+
+    subject { view[key] }
+
+    context 'with existant key' do
+      let(:key) { :animal }
+
+      it { is_expected.to eql('donkey') }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+      end
+
+      it 'creates a dependency with the right props' do
+        subject
+        dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+        expect(dep.props.attributes?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.compiled_content?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+    end
+
+    context 'with non-existant key' do
+      let(:key) { :weapon }
+
+      it { is_expected.to eql(nil) }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+      end
+
+      it 'creates a dependency with the right props' do
+        subject
+        dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+        expect(dep.props.attributes?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.compiled_content?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+    end
+  end
+
+  describe '#attributes' do
+    let(:document) { entity_class.new('stuff', { animal: 'donkey' }, '/foo/') }
+
+    subject { view.attributes }
+
+    it 'creates a dependency' do
+      expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+    end
+
+    it 'creates a dependency with the right props' do
+      subject
+      dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+      expect(dep.props.attributes?).to eq(true)
+
+      expect(dep.props.raw_content?).to eq(false)
+      expect(dep.props.compiled_content?).to eq(false)
+      expect(dep.props.path?).to eq(false)
+    end
+
+    it 'returns attributes' do
+      expect(subject).to eql(animal: 'donkey')
+    end
+  end
+
+  describe '#fetch' do
+    let(:document) { entity_class.new('stuff', { animal: 'donkey' }, '/foo/') }
+
+    context 'with existant key' do
+      let(:key) { :animal }
+
+      subject { view.fetch(key) }
+
+      it { is_expected.to eql('donkey') }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+      end
+
+      it 'creates a dependency with the right props' do
+        subject
+        dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+        expect(dep.props.attributes?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.compiled_content?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+    end
+
+    context 'with non-existant key' do
+      let(:key) { :weapon }
+
+      context 'with fallback' do
+        subject { view.fetch(key, 'nothing sorry') }
+
+        it { is_expected.to eql('nothing sorry') }
+
+        it 'creates a dependency' do
+          expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+        end
+
+        it 'creates a dependency with the right props' do
+          subject
+          dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+          expect(dep.props.attributes?).to eq(true)
+
+          expect(dep.props.raw_content?).to eq(false)
+          expect(dep.props.compiled_content?).to eq(false)
+          expect(dep.props.path?).to eq(false)
+        end
+      end
+
+      context 'with block' do
+        subject { view.fetch(key) { 'nothing sorry' } }
+
+        it { is_expected.to eql('nothing sorry') }
+
+        it 'creates a dependency' do
+          expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+        end
+
+        it 'creates a dependency with the right props' do
+          subject
+          dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+          expect(dep.props.attributes?).to eq(true)
+
+          expect(dep.props.raw_content?).to eq(false)
+          expect(dep.props.compiled_content?).to eq(false)
+          expect(dep.props.path?).to eq(false)
+        end
+      end
+
+      context 'with no fallback and no block' do
+        subject { view.fetch(key) }
+
+        it 'raises' do
+          expect { subject }.to raise_error(KeyError)
+        end
+      end
+    end
+  end
+
+  describe '#key?' do
+    let(:document) { entity_class.new('stuff', { animal: 'donkey' }, '/foo/') }
+
+    subject { view.key?(key) }
+
+    context 'with existant key' do
+      let(:key) { :animal }
+
+      it { is_expected.to eql(true) }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+      end
+
+      it 'creates a dependency with the right props' do
+        subject
+        dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+        expect(dep.props.attributes?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.compiled_content?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+    end
+
+    context 'with non-existant key' do
+      let(:key) { :weapon }
+
+      it { is_expected.to eql(false) }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+      end
+
+      it 'creates a dependency with the right props' do
+        subject
+        dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+        expect(dep.props.attributes?).to eq(true)
+
+        expect(dep.props.raw_content?).to eq(false)
+        expect(dep.props.compiled_content?).to eq(false)
+        expect(dep.props.path?).to eq(false)
+      end
+    end
+  end
+
+  describe '#hash' do
+    let(:document) { double(:document, identifier: '/foo/') }
+
+    subject { view.hash }
+
+    it { should == described_class.hash ^ '/foo/'.hash }
+  end
+
+  describe '#raw_content' do
+    let(:document) { entity_class.new('stuff', { animal: 'donkey' }, '/foo/') }
+
+    subject { view.raw_content }
+
+    it { is_expected.to eql('stuff') }
+
+    it 'creates a dependency' do
+      expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([document])
+    end
+
+    it 'creates a dependency with the right props' do
+      subject
+      dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+      expect(dep.props.raw_content?).to eq(true)
+
+      expect(dep.props.attributes?).to eq(false)
+      expect(dep.props.compiled_content?).to eq(false)
+      expect(dep.props.path?).to eq(false)
+    end
+  end
+end
diff --git a/spec/nanoc/base/views/identifiable_collection_view_spec.rb b/spec/nanoc/base/views/identifiable_collection_view_spec.rb
new file mode 100644
index 0000000..cb7cd5a
--- /dev/null
+++ b/spec/nanoc/base/views/identifiable_collection_view_spec.rb
@@ -0,0 +1,190 @@
+# Needs :view_class
+shared_examples 'an identifiable collection' do
+  let(:view) { described_class.new(wrapped, view_context) }
+
+  let(:view_context) { double(:view_context) }
+
+  let(:config) do
+    { string_pattern_type: 'glob' }
+  end
+
+  describe '#frozen?' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/foo'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/bar'))
+      end
+    end
+
+    subject { view.frozen? }
+
+    context 'non-frozen collection' do
+      it { is_expected.to be(false) }
+    end
+
+    context 'frozen collection' do
+      before do
+        wrapped.each { |o| expect(o).to receive(:freeze) }
+        wrapped.freeze
+      end
+
+      it { is_expected.to be(true) }
+    end
+  end
+
+  describe '#unwrap' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/foo'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/bar'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/baz'))
+      end
+    end
+
+    subject { view.unwrap }
+
+    it { should equal(wrapped) }
+  end
+
+  describe '#each' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/foo'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/bar'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/baz'))
+      end
+    end
+
+    it 'returns self' do
+      expect(view.each { |_i| }).to equal(view)
+    end
+
+    it 'yields elements with the right context' do
+      view.each { |v| expect(v._context).to equal(view_context) }
+    end
+  end
+
+  describe '#size' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/foo'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/bar'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/baz'))
+      end
+    end
+
+    subject { view.size }
+
+    it { should == 3 }
+  end
+
+  describe '#[]' do
+    let(:page_object) do
+      double(:identifiable, identifier: Nanoc::Identifier.new('/page.erb'))
+    end
+
+    let(:home_object) do
+      double(:identifiable, identifier: Nanoc::Identifier.new('/home.erb'))
+    end
+
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << page_object
+        arr << home_object
+      end
+    end
+
+    subject { view[arg] }
+
+    context 'no objects found' do
+      let(:arg) { '/donkey.*' }
+      it { is_expected.to equal(nil) }
+    end
+
+    context 'string' do
+      let(:arg) { '/home.erb' }
+
+      it 'returns wrapped object' do
+        expect(subject.class).to equal(view_class)
+        expect(subject.unwrap).to equal(home_object)
+      end
+
+      it 'returns objects with right context' do
+        expect(subject._context).to equal(view_context)
+      end
+    end
+
+    context 'identifier' do
+      let(:arg) { Nanoc::Identifier.new('/home.erb') }
+
+      it 'returns wrapped object' do
+        expect(subject.class).to equal(view_class)
+        expect(subject.unwrap).to equal(home_object)
+      end
+    end
+
+    context 'glob' do
+      let(:arg) { '/home.*' }
+
+      context 'globs not enabled' do
+        let(:config) { { string_pattern_type: 'legacy' } }
+
+        it 'returns nil' do
+          expect(subject).to be_nil
+        end
+      end
+
+      context 'globs enabled' do
+        it 'returns wrapped object' do
+          expect(subject.class).to equal(view_class)
+          expect(subject.unwrap).to equal(home_object)
+        end
+      end
+    end
+
+    context 'regex' do
+      let(:arg) { %r{\A/home} }
+
+      it 'returns wrapped object' do
+        expect(subject.class).to equal(view_class)
+        expect(subject.unwrap).to equal(home_object)
+      end
+    end
+  end
+
+  describe '#find_all' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |arr|
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/about.css'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/about.md'))
+        arr << double(:identifiable, identifier: Nanoc::Identifier.new('/style.css'))
+      end
+    end
+
+    subject { view.find_all(arg) }
+
+    context 'with string' do
+      let(:arg) { '/*.css' }
+
+      it 'contains views' do
+        expect(subject.size).to eql(2)
+        about_css = subject.find { |iv| iv.identifier == '/about.css' }
+        style_css = subject.find { |iv| iv.identifier == '/style.css' }
+        expect(about_css.class).to equal(view_class)
+        expect(style_css.class).to equal(view_class)
+      end
+    end
+
+    context 'with regex' do
+      let(:arg) { %r{\.css\z} }
+
+      it 'contains views' do
+        expect(subject.size).to eql(2)
+        about_css = subject.find { |iv| iv.identifier == '/about.css' }
+        style_css = subject.find { |iv| iv.identifier == '/style.css' }
+        expect(about_css.class).to equal(view_class)
+        expect(style_css.class).to equal(view_class)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/views/item_collection_with_reps_view_spec.rb b/spec/nanoc/base/views/item_collection_with_reps_view_spec.rb
new file mode 100644
index 0000000..0121fb4
--- /dev/null
+++ b/spec/nanoc/base/views/item_collection_with_reps_view_spec.rb
@@ -0,0 +1,18 @@
+describe Nanoc::ItemCollectionWithRepsView do
+  let(:view_class) { Nanoc::ItemWithRepsView }
+  it_behaves_like 'an identifiable collection'
+
+  describe '#inspect' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config)
+    end
+
+    let(:view) { described_class.new(wrapped, view_context) }
+    let(:view_context) { double(:view_context) }
+    let(:config) { { string_pattern_type: 'glob' } }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::ItemCollectionWithRepsView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/item_collection_without_reps_view_spec.rb b/spec/nanoc/base/views/item_collection_without_reps_view_spec.rb
new file mode 100644
index 0000000..0a54a09
--- /dev/null
+++ b/spec/nanoc/base/views/item_collection_without_reps_view_spec.rb
@@ -0,0 +1,18 @@
+describe Nanoc::ItemCollectionWithoutRepsView do
+  let(:view_class) { Nanoc::ItemWithoutRepsView }
+  it_behaves_like 'an identifiable collection'
+
+  describe '#inspect' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config)
+    end
+
+    let(:view) { described_class.new(wrapped, view_context) }
+    let(:view_context) { double(:view_context) }
+    let(:config) { { string_pattern_type: 'glob' } }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::ItemCollectionWithoutRepsView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/item_rep_collection_view_spec.rb b/spec/nanoc/base/views/item_rep_collection_view_spec.rb
new file mode 100644
index 0000000..6c6df32
--- /dev/null
+++ b/spec/nanoc/base/views/item_rep_collection_view_spec.rb
@@ -0,0 +1,143 @@
+shared_examples 'an item rep collection view' do
+  let(:view) { described_class.new(wrapped, view_context) }
+
+  let(:view_context) { double(:view_context) }
+
+  let(:wrapped) do
+    [
+      double(:item_rep, name: :foo),
+      double(:item_rep, name: :bar),
+      double(:item_rep, name: :baz),
+    ]
+  end
+
+  describe '#unwrap' do
+    subject { view.unwrap }
+
+    it { should equal(wrapped) }
+  end
+
+  describe '#frozen?' do
+    subject { view.frozen? }
+
+    context 'non-frozen collection' do
+      it { is_expected.to be(false) }
+    end
+
+    context 'frozen collection' do
+      before { wrapped.freeze }
+      it { is_expected.to be(true) }
+    end
+  end
+
+  describe '#each' do
+    it 'yields' do
+      actual = [].tap { |res| view.each { |v| res << v } }
+      expect(actual.size).to eq(3)
+    end
+
+    it 'returns self' do
+      expect(view.each { |_i| }).to equal(view)
+    end
+
+    it 'yields elements with the right context' do
+      view.each { |v| expect(v._context).to equal(view_context) }
+    end
+  end
+
+  describe '#size' do
+    subject { view.size }
+
+    it { should == 3 }
+  end
+
+  describe '#to_ary' do
+    subject { view.to_ary }
+
+    it 'returns an array of item rep views' do
+      expect(subject.class).to eq(Array)
+      expect(subject.size).to eq(3)
+      expect(subject[0].class).to eql(expected_view_class)
+      expect(subject[0].name).to eql(:foo)
+    end
+
+    it 'returns an array with correct contexts' do
+      expect(subject[0]._context).to equal(view_context)
+    end
+  end
+
+  describe '#[]' do
+    subject { view[name] }
+
+    context 'when not found' do
+      let(:name) { :donkey }
+
+      it { should be_nil }
+    end
+
+    context 'when found' do
+      let(:name) { :foo }
+
+      it 'returns a view' do
+        expect(subject.class).to eq(expected_view_class)
+        expect(subject.name).to eq(:foo)
+      end
+
+      it 'returns a view with the correct context' do
+        expect(subject._context).to equal(view_context)
+      end
+    end
+
+    context 'when given a string' do
+      let(:name) { 'foo' }
+
+      it 'raises' do
+        expect { subject }.to raise_error(ArgumentError, 'expected ItemRepCollectionView#[] to be called with a symbol')
+      end
+    end
+
+    context 'when given a number' do
+      let(:name) { 0 }
+
+      it 'raises' do
+        expect { subject }.to raise_error(ArgumentError, 'expected ItemRepCollectionView#[] to be called with a symbol (you likely want `.reps[:default]` rather than `.reps[0]`)')
+      end
+    end
+  end
+
+  describe '#fetch' do
+    subject { view.fetch(name) }
+
+    context 'when not found' do
+      let(:name) { :donkey }
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::ItemRepCollectionView::NoSuchItemRepError)
+      end
+    end
+
+    context 'when found' do
+      let(:name) { :foo }
+
+      it 'returns a view' do
+        expect(subject.class).to eq(expected_view_class)
+        expect(subject.name).to eq(:foo)
+      end
+
+      it 'returns a view with the correct context' do
+        expect(subject._context).to equal(view_context)
+      end
+    end
+  end
+
+  describe '#inspect' do
+    subject { view.inspect }
+
+    it { is_expected.to eql('<' + described_class.name + '>') }
+  end
+end
+
+describe Nanoc::ItemRepCollectionView do
+  it_behaves_like 'an item rep collection view'
+  let(:expected_view_class) { Nanoc::ItemRepView }
+end
diff --git a/spec/nanoc/base/views/item_rep_view_spec.rb b/spec/nanoc/base/views/item_rep_view_spec.rb
new file mode 100644
index 0000000..5e897e0
--- /dev/null
+++ b/spec/nanoc/base/views/item_rep_view_spec.rb
@@ -0,0 +1,265 @@
+describe Nanoc::ItemRepView do
+  let(:view_context) { Nanoc::ViewContext.new(reps: reps, items: items, dependency_tracker: dependency_tracker, compilation_context: compilation_context) }
+
+  let(:reps) { double(:reps) }
+  let(:items) { double(:items) }
+  let(:compilation_context) { double(:compilation_context) }
+
+  let(:dependency_tracker) { Nanoc::Int::DependencyTracker.new(dependency_store) }
+  let(:dependency_store) { Nanoc::Int::DependencyStore.new([]) }
+  let(:base_item) { Nanoc::Int::Item.new('base', {}, '/base.md') }
+
+  before do
+    dependency_tracker.enter(base_item)
+  end
+
+  describe '#frozen?' do
+    let(:item_rep) { Nanoc::Int::ItemRep.new(item, :jacques) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo/') }
+    let(:view) { described_class.new(item_rep, view_context) }
+
+    subject { view.frozen? }
+
+    context 'non-frozen item rep' do
+      it { is_expected.to be(false) }
+    end
+
+    context 'frozen item rep' do
+      before { item_rep.freeze }
+      it { is_expected.to be(true) }
+    end
+  end
+
+  describe '#== and #eql?' do
+    let(:item_rep) { Nanoc::Int::ItemRep.new(item, :jacques) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo/') }
+    let(:view) { described_class.new(item_rep, view_context) }
+
+    context 'comparing with item rep with same identifier' do
+      let(:other_item) { double(:other_item, identifier: '/foo/') }
+      let(:other) { double(:other_item_rep, item: other_item, name: :jacques) }
+
+      it 'is ==' do
+        expect(view).to eq(other)
+      end
+
+      it 'is eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with item rep with different identifier' do
+      let(:other_item) { double(:other_item, identifier: '/bar/') }
+      let(:other) { double(:other_item_rep, item: other_item, name: :jacques) }
+
+      it 'is not ==' do
+        expect(view).not_to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with item rep with different name' do
+      let(:other_item) { double(:other_item, identifier: '/foo/') }
+      let(:other) { double(:other_item_rep, item: other_item, name: :marvin) }
+
+      it 'is not ==' do
+        expect(view).not_to eq(other)
+      end
+
+      it 'is not eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with item rep with same identifier' do
+      let(:other_item) { double(:other_item, identifier: '/foo/') }
+      let(:other) { described_class.new(double(:other_item_rep, item: other_item, name: :jacques), view_context) }
+
+      it 'is ==' do
+        expect(view).to eq(other)
+      end
+
+      it 'is eql?' do
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with item rep with different identifier' do
+      let(:other_item) { double(:other_item, identifier: '/bar/') }
+      let(:other) { described_class.new(double(:other_item_rep, item: other_item, name: :jacques), view_context) }
+
+      it 'is not equal' do
+        expect(view).not_to eq(other)
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with item rep with different name' do
+      let(:other_item) { double(:other_item, identifier: '/foo/') }
+      let(:other) { described_class.new(double(:other_item_rep, item: other_item, name: :marvin), view_context) }
+
+      it 'is not equal' do
+        expect(view).not_to eq(other)
+        expect(view).not_to eql(other)
+      end
+    end
+
+    context 'comparing with something that is not an item rep' do
+      let(:other_item) { double(:other_item, identifier: '/foo/') }
+      let(:other) { :donkey }
+
+      it 'is not equal' do
+        expect(view).not_to eq(other)
+        expect(view).not_to eql(other)
+      end
+    end
+  end
+
+  describe '#hash' do
+    let(:item_rep) { Nanoc::Int::ItemRep.new(item, :jacques) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo/') }
+    let(:view) { described_class.new(item_rep, view_context) }
+
+    subject { view.hash }
+
+    it { should == described_class.hash ^ Nanoc::Identifier.new('/foo/').hash ^ :jacques.hash }
+  end
+
+  describe '#compiled_content' do
+    subject { view.compiled_content }
+
+    let(:view) { described_class.new(rep, view_context) }
+
+    let(:rep) do
+      Nanoc::Int::ItemRep.new(item, :default).tap do |ir|
+        ir.compiled = true
+        ir.snapshot_defs = [
+          Nanoc::Int::SnapshotDef.new(:last),
+        ]
+        ir.snapshot_contents = {
+          last: Nanoc::Int::TextualContent.new('Hallo'),
+        }
+      end
+    end
+
+    let(:item) do
+      Nanoc::Int::Item.new('content', {}, '/asdf.md')
+    end
+
+    it 'creates a dependency' do
+      expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+    end
+
+    it 'creates a dependency with the right props' do
+      subject
+      dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+      expect(dep.props.compiled_content?).to eq(true)
+
+      expect(dep.props.raw_content?).to eq(false)
+      expect(dep.props.attributes?).to eq(false)
+      expect(dep.props.path?).to eq(false)
+    end
+
+    it { should eq('Hallo') }
+  end
+
+  describe '#path' do
+    subject { view.path }
+
+    let(:view) { described_class.new(rep, view_context) }
+
+    let(:rep) do
+      Nanoc::Int::ItemRep.new(item, :default).tap do |ir|
+        ir.paths = {
+          last: '/about/',
+        }
+      end
+    end
+
+    let(:item) do
+      Nanoc::Int::Item.new('content', {}, '/asdf.md')
+    end
+
+    it 'creates a dependency' do
+      expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+    end
+
+    it 'creates a dependency with the right props' do
+      subject
+      dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+      expect(dep.props.path?).to eq(true)
+
+      expect(dep.props.raw_content?).to eq(false)
+      expect(dep.props.attributes?).to eq(false)
+      expect(dep.props.compiled_content?).to eq(false)
+    end
+
+    it { should eq('/about/') }
+  end
+
+  describe '#raw_path' do
+    subject { view.raw_path }
+
+    let(:view) { described_class.new(rep, view_context) }
+
+    let(:rep) do
+      Nanoc::Int::ItemRep.new(item, :default).tap do |ir|
+        ir.raw_paths = {
+          last: 'output/about/index.html',
+        }
+      end
+    end
+
+    let(:item) do
+      Nanoc::Int::Item.new('content', {}, '/asdf.md')
+    end
+
+    it 'creates a dependency' do
+      expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+    end
+
+    it 'creates a dependency with the right props' do
+      subject
+      dep = dependency_store.dependencies_causing_outdatedness_of(base_item)[0]
+
+      expect(dep.props.path?).to eq(true)
+
+      expect(dep.props.raw_content?).to eq(false)
+      expect(dep.props.attributes?).to eq(false)
+      expect(dep.props.compiled_content?).to eq(false)
+    end
+
+    it { should eq('output/about/index.html') }
+  end
+
+  describe '#item' do
+    let(:item_rep) { Nanoc::Int::ItemRep.new(item, :jacques) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo/') }
+    let(:view) { described_class.new(item_rep, view_context) }
+
+    subject { view.item }
+
+    it 'returns an item view' do
+      expect(subject).to be_a(Nanoc::ItemWithRepsView)
+    end
+
+    it 'returns an item view with the right context' do
+      expect(subject._context).to equal(view_context)
+    end
+  end
+
+  describe '#inspect' do
+    let(:item_rep) { Nanoc::Int::ItemRep.new(item, :jacques) }
+    let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo/') }
+    let(:view) { described_class.new(item_rep, view_context) }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::ItemRepView item.identifier=/foo/ name=jacques>') }
+  end
+end
diff --git a/spec/nanoc/base/views/item_view_spec.rb b/spec/nanoc/base/views/item_view_spec.rb
new file mode 100644
index 0000000..52502f2
--- /dev/null
+++ b/spec/nanoc/base/views/item_view_spec.rb
@@ -0,0 +1,341 @@
+describe Nanoc::ItemWithRepsView do
+  let(:entity_class) { Nanoc::Int::Item }
+  let(:other_view_class) { Nanoc::LayoutView }
+  it_behaves_like 'a document view'
+
+  let(:view_context) { Nanoc::ViewContext.new(reps: reps, items: items, dependency_tracker: dependency_tracker, compilation_context: compilation_context) }
+  let(:reps) { [] }
+  let(:items) { [] }
+  let(:dependency_tracker) { Nanoc::Int::DependencyTracker.new(dependency_store) }
+  let(:dependency_store) { Nanoc::Int::DependencyStore.new([]) }
+  let(:compilation_context) { double(:compilation_context) }
+
+  let(:base_item) { Nanoc::Int::Item.new('base', {}, '/base.md') }
+
+  before do
+    dependency_tracker.enter(base_item)
+  end
+
+  describe '#parent' do
+    let(:item) do
+      Nanoc::Int::Item.new('me', {}, identifier)
+    end
+
+    let(:view) { described_class.new(item, view_context) }
+
+    let(:items) do
+      Nanoc::Int::IdentifiableCollection.new({}).tap do |arr|
+        arr << item
+        arr << parent_item if parent_item
+      end
+    end
+
+    subject { view.parent }
+
+    context 'with parent' do
+      let(:parent_item) do
+        Nanoc::Int::Item.new('parent', {}, '/parent/')
+      end
+
+      context 'full identifier' do
+        let(:identifier) do
+          Nanoc::Identifier.new('/parent/me.md')
+        end
+
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::CannotGetParentOrChildrenOfNonLegacyItem)
+        end
+      end
+
+      context 'legacy identifier' do
+        let(:identifier) do
+          Nanoc::Identifier.new('/parent/me/', type: :legacy)
+        end
+
+        it 'returns a view for the parent' do
+          expect(subject.class).to eql(Nanoc::ItemWithRepsView)
+          expect(subject.unwrap).to eql(parent_item)
+        end
+
+        it 'returns a view with the right context' do
+          expect(subject._context).to equal(view_context)
+        end
+
+        context 'frozen parent' do
+          before { parent_item.freeze }
+          it { is_expected.to be_frozen }
+        end
+
+        context 'non-frozen parent' do
+          it { is_expected.not_to be_frozen }
+        end
+
+        context 'with root parent' do
+          let(:parent_item) { Nanoc::Int::Item.new('parent', {}, '/') }
+          let(:identifier) { Nanoc::Identifier.new('/me/', type: :legacy) }
+
+          it 'returns a view for the parent' do
+            expect(subject.class).to eql(Nanoc::ItemWithRepsView)
+            expect(subject.unwrap).to eql(parent_item)
+          end
+        end
+      end
+    end
+
+    context 'without parent' do
+      let(:parent_item) do
+        nil
+      end
+
+      context 'full identifier' do
+        let(:identifier) do
+          Nanoc::Identifier.new('/me.md')
+        end
+
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::CannotGetParentOrChildrenOfNonLegacyItem)
+        end
+      end
+
+      context 'legacy identifier' do
+        let(:identifier) do
+          Nanoc::Identifier.new('/me/', type: :legacy)
+        end
+
+        it { is_expected.to be_nil }
+        it { is_expected.to be_frozen }
+      end
+    end
+  end
+
+  describe '#children' do
+    let(:item) do
+      Nanoc::Int::Item.new('me', {}, identifier)
+    end
+
+    let(:children) do
+      [Nanoc::Int::Item.new('child', {}, '/me/child/')]
+    end
+
+    let(:view) { described_class.new(item, view_context) }
+
+    let(:items) do
+      Nanoc::Int::IdentifiableCollection.new({}).tap do |arr|
+        arr << item
+        children.each { |child| arr << child }
+      end
+    end
+
+    subject { view.children }
+
+    context 'full identifier' do
+      let(:identifier) do
+        Nanoc::Identifier.new('/me.md')
+      end
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::CannotGetParentOrChildrenOfNonLegacyItem)
+      end
+    end
+
+    context 'legacy identifier' do
+      let(:identifier) do
+        Nanoc::Identifier.new('/me/', type: :legacy)
+      end
+
+      it 'returns views for the children' do
+        expect(subject.size).to eql(1)
+        expect(subject[0].class).to eql(Nanoc::ItemWithRepsView)
+        expect(subject[0].unwrap).to eql(children[0])
+      end
+
+      it { is_expected.to be_frozen }
+    end
+  end
+
+  describe '#reps' do
+    let(:item) { Nanoc::Int::Item.new('blah', {}, '/foo.md') }
+    let(:rep_a) { Nanoc::Int::ItemRep.new(item, :a) }
+    let(:rep_b) { Nanoc::Int::ItemRep.new(item, :b) }
+
+    let(:reps) do
+      Nanoc::Int::ItemRepRepo.new.tap do |reps|
+        reps << rep_a
+        reps << rep_b
+      end
+    end
+
+    let(:view) { described_class.new(item, view_context) }
+
+    subject { view.reps }
+
+    it 'returns a proper item rep collection' do
+      expect(subject.size).to eq(2)
+      expect(subject.class).to eql(Nanoc::ItemRepCollectionView)
+    end
+
+    it 'returns a view with the right context' do
+      expect(subject._context).to eq(view_context)
+    end
+  end
+
+  describe '#compiled_content' do
+    subject { view.compiled_content(params) }
+
+    let(:view) { described_class.new(item, view_context) }
+
+    let(:item) do
+      Nanoc::Int::Item.new('content', {}, '/asdf/')
+    end
+
+    let(:reps) do
+      Nanoc::Int::ItemRepRepo.new.tap do |reps|
+        reps << rep
+      end
+    end
+
+    let(:rep) do
+      Nanoc::Int::ItemRep.new(item, :default).tap do |ir|
+        ir.compiled = true
+        ir.snapshot_defs = [
+          Nanoc::Int::SnapshotDef.new(:last),
+          Nanoc::Int::SnapshotDef.new(:pre),
+          Nanoc::Int::SnapshotDef.new(:post),
+          Nanoc::Int::SnapshotDef.new(:specific),
+        ]
+        ir.snapshot_contents = {
+          last: Nanoc::Int::TextualContent.new('Last Hallo'),
+          pre: Nanoc::Int::TextualContent.new('Pre Hallo'),
+          post: Nanoc::Int::TextualContent.new('Post Hallo'),
+          specific: Nanoc::Int::TextualContent.new('Specific Hallo'),
+        }
+      end
+    end
+
+    context 'requesting implicit default rep' do
+      let(:params) { {} }
+
+      it { is_expected.to eq('Pre Hallo') }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+      end
+
+      context 'requesting explicit snapshot' do
+        let(:params) { { snapshot: :specific } }
+
+        it { is_expected.to eq('Specific Hallo') }
+
+        it 'creates a dependency' do
+          expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+        end
+      end
+    end
+
+    context 'requesting explicit default rep' do
+      let(:params) { { rep: :default } }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+      end
+
+      it { is_expected.to eq('Pre Hallo') }
+
+      context 'requesting explicit snapshot' do
+        let(:params) { { snapshot: :specific } }
+
+        it { is_expected.to eq('Specific Hallo') }
+      end
+    end
+
+    context 'requesting other rep' do
+      let(:params) { { rep: :other } }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::ItemRepCollectionView::NoSuchItemRepError)
+      end
+    end
+  end
+
+  describe '#path' do
+    subject { view.path(params) }
+
+    let(:view) { described_class.new(item, view_context) }
+
+    let(:item) do
+      Nanoc::Int::Item.new('content', {}, '/asdf.md')
+    end
+
+    let(:reps) do
+      Nanoc::Int::ItemRepRepo.new.tap do |reps|
+        reps << rep
+      end
+    end
+
+    let(:rep) do
+      Nanoc::Int::ItemRep.new(item, :default).tap do |ir|
+        ir.paths = {
+          last: '/about/',
+          specific: '/about.txt',
+        }
+      end
+    end
+
+    context 'requesting implicit default rep' do
+      let(:params) { {} }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+      end
+
+      it { is_expected.to eq('/about/') }
+
+      context 'requesting explicit snapshot' do
+        let(:params) { { snapshot: :specific } }
+
+        it { is_expected.to eq('/about.txt') }
+      end
+    end
+
+    context 'requesting explicit default rep' do
+      let(:params) { { rep: :default } }
+
+      it 'creates a dependency' do
+        expect { subject }.to change { dependency_store.objects_causing_outdatedness_of(base_item) }.from([]).to([item])
+      end
+
+      it { is_expected.to eq('/about/') }
+
+      context 'requesting explicit snapshot' do
+        let(:params) { { snapshot: :specific } }
+
+        it { is_expected.to eq('/about.txt') }
+      end
+    end
+
+    context 'requesting other rep' do
+      let(:params) { { rep: :other } }
+
+      it 'raises an error' do
+        expect { subject }.to raise_error(Nanoc::ItemRepCollectionView::NoSuchItemRepError)
+      end
+    end
+  end
+
+  describe '#binary?' do
+    # TODO: implement
+  end
+
+  describe '#raw_filename' do
+    # TODO: implement
+  end
+
+  describe '#inspect' do
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/asdf/') }
+    let(:view) { described_class.new(item, nil) }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::ItemWithRepsView identifier=/asdf/>') }
+  end
+end
diff --git a/spec/nanoc/base/views/layout_collection_view_spec.rb b/spec/nanoc/base/views/layout_collection_view_spec.rb
new file mode 100644
index 0000000..054f61d
--- /dev/null
+++ b/spec/nanoc/base/views/layout_collection_view_spec.rb
@@ -0,0 +1,18 @@
+describe Nanoc::LayoutCollectionView do
+  let(:view_class) { Nanoc::LayoutView }
+  it_behaves_like 'an identifiable collection'
+
+  describe '#inspect' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config)
+    end
+
+    let(:view) { described_class.new(wrapped, view_context) }
+    let(:view_context) { double(:view_context) }
+    let(:config) { { string_pattern_type: 'glob' } }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::LayoutCollectionView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/layout_view_spec.rb b/spec/nanoc/base/views/layout_view_spec.rb
new file mode 100644
index 0000000..d682d8f
--- /dev/null
+++ b/spec/nanoc/base/views/layout_view_spec.rb
@@ -0,0 +1,14 @@
+describe Nanoc::LayoutView do
+  let(:entity_class) { Nanoc::Int::Layout }
+  let(:other_view_class) { Nanoc::ItemWithRepsView }
+  it_behaves_like 'a document view'
+
+  describe '#inspect' do
+    let(:item) { Nanoc::Int::Layout.new('content', {}, '/asdf/') }
+    let(:view) { described_class.new(item, nil) }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::LayoutView identifier=/asdf/>') }
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_config_view_spec.rb b/spec/nanoc/base/views/mutable_config_view_spec.rb
new file mode 100644
index 0000000..d97adb0
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_config_view_spec.rb
@@ -0,0 +1,16 @@
+describe Nanoc::MutableConfigView do
+  let(:config) { {} }
+  let(:view) { described_class.new(config, nil) }
+
+  describe '#[]=' do
+    it 'sets attributes' do
+      view[:awesomeness] = 'rather high'
+      expect(config[:awesomeness]).to eq('rather high')
+    end
+  end
+
+  describe '#inspect' do
+    subject { view.inspect }
+    it { is_expected.to eql('<Nanoc::MutableConfigView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_document_view_spec.rb b/spec/nanoc/base/views/mutable_document_view_spec.rb
new file mode 100644
index 0000000..cacbd18
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_document_view_spec.rb
@@ -0,0 +1,92 @@
+shared_examples 'a mutable document view' do
+  let(:view) { described_class.new(item, view_context) }
+
+  let(:view_context) do
+    Nanoc::ViewContext.new(
+      reps: double(:reps),
+      items: double(:items),
+      dependency_tracker: dependency_tracker,
+      compilation_context: double(:compilation_context),
+    )
+  end
+
+  let(:dependency_tracker) { Nanoc::Int::DependencyTracker.new(double(:dependency_store)) }
+
+  describe '#[]=' do
+    # FIXME: rename :item to :document
+    let(:item) { entity_class.new('content', {}, '/asdf/') }
+
+    it 'sets attributes' do
+      view[:title] = 'Donkey'
+      expect(view[:title]).to eq('Donkey')
+    end
+
+    it 'disallows items' do
+      item = Nanoc::Int::Item.new('content', {}, '/foo.md')
+      expect { view[:item] = item }.to raise_error(Nanoc::MutableDocumentViewMixin::DisallowedAttributeValueError)
+    end
+
+    it 'disallows layouts' do
+      layout = Nanoc::Int::Layout.new('content', {}, '/foo.md')
+      expect { view[:layout] = layout }.to raise_error(Nanoc::MutableDocumentViewMixin::DisallowedAttributeValueError)
+    end
+
+    it 'disallows item views' do
+      item = Nanoc::ItemWithRepsView.new(Nanoc::Int::Item.new('content', {}, '/foo.md'), nil)
+      expect { view[:item] = item }.to raise_error(Nanoc::MutableDocumentViewMixin::DisallowedAttributeValueError)
+    end
+
+    it 'disallows layout views' do
+      layout = Nanoc::LayoutView.new(Nanoc::Int::Layout.new('content', {}, '/foo.md'), nil)
+      expect { view[:layout] = layout }.to raise_error(Nanoc::MutableDocumentViewMixin::DisallowedAttributeValueError)
+    end
+  end
+
+  describe '#identifier=' do
+    let(:item) { entity_class.new('content', {}, '/about.md') }
+
+    subject { view.identifier = arg }
+
+    context 'given a string' do
+      let(:arg) { '/about.adoc' }
+
+      it 'changes the identifier' do
+        subject
+        expect(view.identifier).to eq('/about.adoc')
+      end
+    end
+
+    context 'given an identifier' do
+      let(:arg) { Nanoc::Identifier.new('/about.adoc') }
+
+      it 'changes the identifier' do
+        subject
+        expect(view.identifier).to eq('/about.adoc')
+      end
+    end
+
+    context 'given anything else' do
+      let(:arg) { :donkey }
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Identifier::NonCoercibleObjectError)
+      end
+    end
+  end
+
+  describe '#update_attributes' do
+    let(:item) { entity_class.new('content', {}, '/asdf/') }
+
+    let(:update) { { friend: 'Giraffe' } }
+
+    subject { view.update_attributes(update) }
+
+    it 'sets attributes' do
+      expect { subject }.to change { view[:friend] }.from(nil).to('Giraffe')
+    end
+
+    it 'returns self' do
+      expect(subject).to equal(view)
+    end
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_identifiable_collection_view_spec.rb b/spec/nanoc/base/views/mutable_identifiable_collection_view_spec.rb
new file mode 100644
index 0000000..1fff44c
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_identifiable_collection_view_spec.rb
@@ -0,0 +1,36 @@
+shared_examples 'a mutable identifiable collection' do
+  let(:view) { described_class.new(wrapped, view_context) }
+
+  let(:view_context) { double(:view_context) }
+
+  let(:config) do
+    {}
+  end
+
+  describe '#delete_if' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |coll|
+        coll << double(:identifiable, identifier: Nanoc::Identifier.new('/asdf/'))
+      end
+    end
+
+    it 'deletes matching' do
+      view.delete_if { |i| i.identifier == '/asdf/' }
+      expect(wrapped).to be_empty
+    end
+
+    it 'deletes no non-matching' do
+      view.delete_if { |i| i.identifier == '/blah/' }
+      expect(wrapped).not_to be_empty
+    end
+
+    it 'returns self' do
+      ret = view.delete_if { |_i| false }
+      expect(ret).to equal(view)
+    end
+
+    it 'yields items with the proper context' do
+      view.delete_if { |i| expect(i._context).to equal(view_context) }
+    end
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_item_collection_view_spec.rb b/spec/nanoc/base/views/mutable_item_collection_view_spec.rb
new file mode 100644
index 0000000..ef8d2c8
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_item_collection_view_spec.rb
@@ -0,0 +1,49 @@
+describe Nanoc::MutableItemCollectionView do
+  let(:view_class) { Nanoc::MutableItemView }
+  it_behaves_like 'an identifiable collection'
+  it_behaves_like 'a mutable identifiable collection'
+
+  let(:config) do
+    { string_pattern_type: 'glob' }
+  end
+
+  describe '#create' do
+    let(:item) do
+      Nanoc::Int::Layout.new('content', {}, '/asdf/')
+    end
+
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |coll|
+        coll << item
+      end
+    end
+
+    let(:view) { described_class.new(wrapped, nil) }
+
+    it 'creates an object' do
+      view.create('new content', { title: 'New Page' }, '/new/')
+
+      expect(wrapped.size).to eq(2)
+      expect(wrapped['/new/'].content.string).to eq('new content')
+    end
+
+    it 'returns self' do
+      ret = view.create('new content', { title: 'New Page' }, '/new/')
+      expect(ret).to equal(view)
+    end
+  end
+
+  describe '#inspect' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config)
+    end
+
+    let(:view) { described_class.new(wrapped, view_context) }
+    let(:view_context) { double(:view_context) }
+    let(:config) { { string_pattern_type: 'glob' } }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::MutableItemCollectionView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_item_view_spec.rb b/spec/nanoc/base/views/mutable_item_view_spec.rb
new file mode 100644
index 0000000..c42b241
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_item_view_spec.rb
@@ -0,0 +1,22 @@
+describe Nanoc::MutableItemView do
+  let(:entity_class) { Nanoc::Int::Item }
+  it_behaves_like 'a mutable document view'
+
+  let(:item) { entity_class.new('content', {}, '/asdf/') }
+  let(:view) { described_class.new(item, nil) }
+
+  it 'does have rep access' do
+    expect(view).not_to respond_to(:compiled_content)
+    expect(view).not_to respond_to(:path)
+    expect(view).not_to respond_to(:reps)
+  end
+
+  describe '#inspect' do
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/asdf/') }
+    let(:view) { described_class.new(item, nil) }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::MutableItemView identifier=/asdf/>') }
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_layout_collection_view_spec.rb b/spec/nanoc/base/views/mutable_layout_collection_view_spec.rb
new file mode 100644
index 0000000..b57c108
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_layout_collection_view_spec.rb
@@ -0,0 +1,49 @@
+describe Nanoc::MutableLayoutCollectionView do
+  let(:view_class) { Nanoc::MutableLayoutView }
+  it_behaves_like 'an identifiable collection'
+  it_behaves_like 'a mutable identifiable collection'
+
+  let(:config) do
+    { string_pattern_type: 'glob' }
+  end
+
+  describe '#create' do
+    let(:layout) do
+      Nanoc::Int::Layout.new('content', {}, '/asdf/')
+    end
+
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |coll|
+        coll << layout
+      end
+    end
+
+    let(:view) { described_class.new(wrapped, nil) }
+
+    it 'creates an object' do
+      view.create('new content', { title: 'New Page' }, '/new/')
+
+      expect(wrapped.size).to eq(2)
+      expect(wrapped['/new/'].content.string).to eq('new content')
+    end
+
+    it 'returns self' do
+      ret = view.create('new content', { title: 'New Page' }, '/new/')
+      expect(ret).to equal(view)
+    end
+  end
+
+  describe '#inspect' do
+    let(:wrapped) do
+      Nanoc::Int::IdentifiableCollection.new(config)
+    end
+
+    let(:view) { described_class.new(wrapped, view_context) }
+    let(:view_context) { double(:view_context) }
+    let(:config) { { string_pattern_type: 'glob' } }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::MutableLayoutCollectionView>') }
+  end
+end
diff --git a/spec/nanoc/base/views/mutable_layout_view_spec.rb b/spec/nanoc/base/views/mutable_layout_view_spec.rb
new file mode 100644
index 0000000..91d93e8
--- /dev/null
+++ b/spec/nanoc/base/views/mutable_layout_view_spec.rb
@@ -0,0 +1,13 @@
+describe Nanoc::MutableLayoutView do
+  let(:entity_class) { Nanoc::Int::Layout }
+  it_behaves_like 'a mutable document view'
+
+  describe '#inspect' do
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/asdf/') }
+    let(:view) { described_class.new(item, nil) }
+
+    subject { view.inspect }
+
+    it { is_expected.to eql('<Nanoc::MutableLayoutView identifier=/asdf/>') }
+  end
+end
diff --git a/spec/nanoc/base/views/post_compile_item_rep_collection_view_spec.rb b/spec/nanoc/base/views/post_compile_item_rep_collection_view_spec.rb
new file mode 100644
index 0000000..6dd9bcf
--- /dev/null
+++ b/spec/nanoc/base/views/post_compile_item_rep_collection_view_spec.rb
@@ -0,0 +1,4 @@
+describe Nanoc::PostCompileItemRepCollectionView do
+  it_behaves_like 'an item rep collection view'
+  let(:expected_view_class) { Nanoc::PostCompileItemRepView }
+end
diff --git a/spec/nanoc/base/views/post_compile_item_rep_view_spec.rb b/spec/nanoc/base/views/post_compile_item_rep_view_spec.rb
new file mode 100644
index 0000000..f167732
--- /dev/null
+++ b/spec/nanoc/base/views/post_compile_item_rep_view_spec.rb
@@ -0,0 +1,137 @@
+describe Nanoc::PostCompileItemRepView do
+  let(:item_rep) { Nanoc::Int::ItemRep.new(item, :jacques) }
+  let(:item) { Nanoc::Int::Item.new('asdf', {}, '/foo/') }
+  let(:view) { described_class.new(item_rep, view_context) }
+
+  let(:view_context) do
+    Nanoc::ViewContext.new(
+      reps: reps,
+      items: items,
+      dependency_tracker: dependency_tracker,
+      compilation_context: compilation_context,
+    )
+  end
+
+  let(:reps) { double(:reps) }
+  let(:items) { Nanoc::Int::IdentifiableCollection.new(config) }
+  let(:config) { Nanoc::Int::Configuration.new }
+  let(:dependency_tracker) { Nanoc::Int::DependencyTracker.new(double(:dependency_store)) }
+  let(:compilation_context) { double(:compilation_context, compiled_content_cache: compiled_content_cache) }
+
+  let(:snapshot_contents) do
+    {
+      last: Nanoc::Int::TextualContent.new('content-last'),
+      pre: Nanoc::Int::TextualContent.new('content-pre'),
+      donkey: Nanoc::Int::TextualContent.new('content-donkey'),
+    }
+  end
+
+  let(:compiled_content_cache) do
+    Nanoc::Int::CompiledContentCache.new(items: items).tap do |ccc|
+      ccc[item_rep] = snapshot_contents
+    end
+  end
+
+  describe '#compiled_content' do
+    subject { view.compiled_content }
+
+    context 'binary' do
+      let(:item) do
+        content = Nanoc::Int::Content.create('/foo.dat', binary: true)
+        Nanoc::Int::Item.new(content, {}, '/foo.dat')
+      end
+
+      it 'raises error' do
+        err = Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem
+        expect { subject }.to raise_error(err)
+      end
+    end
+
+    shared_examples 'returns pre content' do
+      example { expect(subject).to eq('content-pre') }
+    end
+
+    shared_examples 'returns last content' do
+      example { expect(subject).to eq('content-last') }
+    end
+
+    shared_examples 'returns donkey content' do
+      example { expect(subject).to eq('content-donkey') }
+    end
+
+    shared_examples 'raises no-such-snapshot error' do
+      it 'raises error' do
+        err = Nanoc::Int::Errors::NoSuchSnapshot
+        expect { subject }.to raise_error(err)
+      end
+    end
+
+    context 'textual' do
+      context 'snapshot provided' do
+        subject { view.compiled_content(snapshot: :donkey) }
+        let(:expected_snapshot) { :donkey }
+
+        context 'snapshot exists' do
+          include_examples 'returns donkey content'
+        end
+
+        context 'snapshot does not exist' do
+          let(:snapshot_contents) do
+            {
+              last: Nanoc::Int::TextualContent.new('content-last'),
+              pre: Nanoc::Int::TextualContent.new('content-pre'),
+            }
+          end
+
+          include_examples 'raises no-such-snapshot error'
+        end
+      end
+
+      context 'no snapshot provided' do
+        context 'pre and last snapshots exist' do
+          let(:snapshot_contents) do
+            {
+              last: Nanoc::Int::TextualContent.new('content-last'),
+              pre: Nanoc::Int::TextualContent.new('content-pre'),
+              donkey: Nanoc::Int::TextualContent.new('content-donkey'),
+            }
+          end
+
+          include_examples 'returns pre content'
+        end
+
+        context 'pre snapshot exists' do
+          let(:snapshot_contents) do
+            {
+              pre: Nanoc::Int::TextualContent.new('content-pre'),
+              donkey: Nanoc::Int::TextualContent.new('content-donkey'),
+            }
+          end
+
+          include_examples 'returns pre content'
+        end
+
+        context 'last snapshot exists' do
+          let(:snapshot_contents) do
+            {
+              last: Nanoc::Int::TextualContent.new('content-last'),
+              donkey: Nanoc::Int::TextualContent.new('content-donkey'),
+            }
+          end
+
+          include_examples 'returns last content'
+        end
+
+        context 'neither pre nor last snapshot exists' do
+          let(:snapshot_contents) do
+            {
+              donkey: Nanoc::Int::TextualContent.new('content-donkey'),
+            }
+          end
+
+          include_examples 'raises no-such-snapshot error'
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/base/views/post_compile_item_view_spec.rb b/spec/nanoc/base/views/post_compile_item_view_spec.rb
new file mode 100644
index 0000000..80b451e
--- /dev/null
+++ b/spec/nanoc/base/views/post_compile_item_view_spec.rb
@@ -0,0 +1,56 @@
+describe Nanoc::PostCompileItemView do
+  let(:item) { Nanoc::Int::Item.new('blah', {}, '/foo.md') }
+  let(:rep_a) { Nanoc::Int::ItemRep.new(item, :no_mod) }
+  let(:rep_b) { Nanoc::Int::ItemRep.new(item, :modded).tap { |r| r.modified = true } }
+
+  let(:reps) do
+    Nanoc::Int::ItemRepRepo.new.tap do |reps|
+      reps << rep_a
+      reps << rep_b
+    end
+  end
+
+  let(:view_context) { double(:view_context, reps: reps) }
+  let(:view) { described_class.new(item, view_context) }
+
+  shared_examples 'a method that returns modified reps only' do
+    it 'returns only modified items' do
+      expect(subject.size).to eq(1)
+      expect(subject.map(&:name)).to eq(%i(modded))
+    end
+
+    it 'returns an array' do
+      expect(subject.class).to eql(Array)
+    end
+  end
+
+  shared_examples 'a method that returns PostCompileItemRepViews' do
+    it 'returns PostCompileItemRepViews' do
+      expect(subject).to all(be_a(Nanoc::PostCompileItemRepView))
+    end
+  end
+
+  describe '#modified_reps' do
+    subject { view.modified_reps }
+
+    it_behaves_like 'a method that returns modified reps only'
+    it_behaves_like 'a method that returns PostCompileItemRepViews'
+  end
+
+  describe '#modified' do
+    subject { view.modified }
+
+    it_behaves_like 'a method that returns modified reps only'
+    it_behaves_like 'a method that returns PostCompileItemRepViews'
+  end
+
+  describe '#reps' do
+    subject { view.reps }
+
+    it_behaves_like 'a method that returns PostCompileItemRepViews'
+
+    it 'returns a PostCompileItemRepCollectionView' do
+      expect(subject).to be_a(Nanoc::PostCompileItemRepCollectionView)
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/compile/file_action_printer_spec.rb b/spec/nanoc/cli/commands/compile/file_action_printer_spec.rb
new file mode 100644
index 0000000..5977af1
--- /dev/null
+++ b/spec/nanoc/cli/commands/compile/file_action_printer_spec.rb
@@ -0,0 +1,76 @@
+describe Nanoc::CLI::Commands::Compile::FileActionPrinter, stdio: true do
+  let(:listener) { described_class.new(reps: reps) }
+
+  before { Timecop.freeze(Time.local(2008, 1, 2, 14, 5, 0)) }
+  after { Timecop.return }
+
+  let(:reps) do
+    Nanoc::Int::ItemRepRepo.new.tap do |reps|
+      reps << rep
+    end
+  end
+
+  let(:item) { Nanoc::Int::Item.new('<%= 1 + 2 %>', {}, '/hi.md') }
+
+  let(:rep) do
+    Nanoc::Int::ItemRep.new(item, :default).tap do |rep|
+      rep.raw_paths = { default: '/hi.html' }
+    end
+  end
+
+  it 'records from compilation_started to rep_written' do
+    listener.start
+
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 0))
+    Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 1))
+
+    expect { Nanoc::Int::NotificationCenter.post(:rep_written, rep, '/foo.html', true, true) }
+      .to output(/create.*\[1\.00s\]/).to_stdout
+  end
+
+  it 'stops listening after #stop' do
+    listener.start
+    listener.stop
+
+    Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+
+    expect { Nanoc::Int::NotificationCenter.post(:rep_written, rep, '/foo.html', true, true) }
+      .not_to output(/create/).to_stdout
+  end
+
+  it 'records from compilation_started over compilation_suspended to rep_written' do
+    listener.start
+
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 0))
+    Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 1))
+    Nanoc::Int::NotificationCenter.post(:compilation_suspended, rep, :__irrelevant__)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 3))
+    Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 6))
+
+    expect { Nanoc::Int::NotificationCenter.post(:rep_written, rep, '/foo.html', true, true) }
+      .to output(/create.*\[4\.00s\]/).to_stdout
+  end
+
+  context 'log level = high' do
+    before { listener.start }
+    before { Nanoc::CLI::Logger.instance.level = :high }
+
+    it 'prints skipped (uncompiled) reps' do
+      expect { listener.stop }
+        .not_to output(/skip/).to_stdout
+    end
+  end
+
+  context 'log level = low' do
+    before { listener.start }
+    before { Nanoc::CLI::Logger.instance.level = :low }
+
+    it 'prints nothing' do
+      expect { listener.stop }
+        .to output(/skip.*\/hi\.html/).to_stdout
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/compile/timing_recorder_spec.rb b/spec/nanoc/cli/commands/compile/timing_recorder_spec.rb
new file mode 100644
index 0000000..edb900e
--- /dev/null
+++ b/spec/nanoc/cli/commands/compile/timing_recorder_spec.rb
@@ -0,0 +1,66 @@
+describe Nanoc::CLI::Commands::Compile::TimingRecorder, stdio: true do
+  let(:listener) { described_class.new(reps: reps) }
+
+  before { Timecop.freeze(Time.local(2008, 1, 2, 14, 5, 0)) }
+  after { Timecop.return }
+
+  let(:reps) do
+    Nanoc::Int::ItemRepRepo.new.tap do |reps|
+      reps << rep
+    end
+  end
+
+  let(:item) { Nanoc::Int::Item.new('<%= 1 + 2 %>', {}, '/hi.md') }
+
+  let(:rep) do
+    Nanoc::Int::ItemRep.new(item, :default).tap do |rep|
+      rep.raw_paths = { default: '/hi.html' }
+    end
+  end
+
+  it 'records single from filtering_started to filtering_ended' do
+    listener.start
+
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 0))
+    Nanoc::Int::NotificationCenter.post(:filtering_started, rep, :erb)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 1))
+    Nanoc::Int::NotificationCenter.post(:filtering_ended, rep, :erb)
+
+    expect { listener.stop }
+      .to output(/^erb \|     1  1\.00s  1\.00s  1\.00s   1\.00s$/).to_stdout
+  end
+
+  it 'records multiple from filtering_started to filtering_ended' do
+    listener.start
+
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 0))
+    Nanoc::Int::NotificationCenter.post(:filtering_started, rep, :erb)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 1))
+    Nanoc::Int::NotificationCenter.post(:filtering_ended, rep, :erb)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 14, 1))
+    Nanoc::Int::NotificationCenter.post(:filtering_started, rep, :erb)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 14, 3))
+    Nanoc::Int::NotificationCenter.post(:filtering_ended, rep, :erb)
+
+    expect { listener.stop }
+      .to output(/^erb \|     2  1\.00s  1\.50s  2\.00s   3\.00s$/).to_stdout
+  end
+
+  it 'records single from filtering_started over compilation_{suspended,started} to filtering_ended' do
+    listener.start
+
+    Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 0))
+    Nanoc::Int::NotificationCenter.post(:filtering_started, rep, :erb)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 1))
+    Nanoc::Int::NotificationCenter.post(:compilation_suspended, rep, :__anything__)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 3))
+    Nanoc::Int::NotificationCenter.post(:compilation_started, rep)
+    Timecop.freeze(Time.local(2008, 9, 1, 10, 5, 7))
+    Nanoc::Int::NotificationCenter.post(:filtering_ended, rep, :erb)
+
+    # FIXME: wrong count (should be 1, not 2)
+    expect { listener.stop }
+      .to output(/^erb \|     2  1\.00s  2\.50s  4\.00s   5\.00s$/).to_stdout
+  end
+end
diff --git a/spec/nanoc/cli/commands/compile_spec.rb b/spec/nanoc/cli/commands/compile_spec.rb
new file mode 100644
index 0000000..2e6686c
--- /dev/null
+++ b/spec/nanoc/cli/commands/compile_spec.rb
@@ -0,0 +1,64 @@
+describe Nanoc::CLI::Commands::Compile::Listener do
+  let(:klass) do
+    Class.new(described_class) do
+      attr_reader :started
+      attr_reader :stopped
+
+      def initialize
+        @started = false
+        @stopped = false
+      end
+
+      def start
+        @started = true
+      end
+
+      def stop
+        @stopped = true
+      end
+    end
+  end
+
+  subject { klass.new }
+
+  it 'starts' do
+    subject.start
+    expect(subject.started).to be
+  end
+
+  it 'stops' do
+    subject.start
+    subject.stop
+    expect(subject.stopped).to be
+  end
+
+  it 'starts safely' do
+    subject.start_safely
+    expect(subject.started).to be
+  end
+
+  it 'stops safely' do
+    subject.start_safely
+    subject.stop_safely
+    expect(subject.stopped).to be
+  end
+
+  context 'listener that does not start or stop properly' do
+    let(:klass) do
+      Class.new(described_class) do
+        def start
+          raise 'boom'
+        end
+
+        def stop
+          raise 'boom'
+        end
+      end
+    end
+
+    it 'raises on start, but not stop' do
+      expect { subject.start_safely }.to raise_error(RuntimeError)
+      expect { subject.stop_safely }.not_to raise_error
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/deploy_spec.rb b/spec/nanoc/cli/commands/deploy_spec.rb
new file mode 100644
index 0000000..4c25168
--- /dev/null
+++ b/spec/nanoc/cli/commands/deploy_spec.rb
@@ -0,0 +1,327 @@
+describe Nanoc::CLI::Commands::Shell, site: true, stdio: true do
+  describe '#run' do
+    let(:config) { {} }
+
+    before do
+      # Prevent double-loading
+      expect(Nanoc::CLI).to receive(:setup)
+
+      File.write('nanoc.yaml', YAML.dump(config))
+    end
+
+    shared_examples 'no effective deploy' do
+      it 'does not write any files' do
+        expect { run rescue nil }.not_to change { Dir['remote/*'] }
+        expect(Dir['remote/*']).to be_empty
+      end
+    end
+
+    shared_examples 'effective deploy' do
+      it 'writes files' do
+        expect { run }.to change { Dir['remote/*'] }.from([]).to(['remote/success.txt'])
+        expect(File.read('remote/success.txt')).to eql('hurrah')
+      end
+    end
+
+    shared_examples 'attempted/effective deploy' do
+      context 'no checks' do
+        include_examples 'effective deploy'
+      end
+
+      context 'checks fail' do
+        before do
+          File.write(
+            'Checks',
+            "check :donkey do\n" \
+            "  add_issue('things are broken', subject: 'success.txt')\n" \
+            "end\n" \
+            "\n" \
+            "deploy_check :donkey\n",
+          )
+        end
+
+        include_examples 'no effective deploy'
+
+        context 'checks disabled' do
+          context '--no-check' do
+            let(:command) { super() + ['--no-check'] }
+            include_examples 'effective deploy'
+          end
+
+          context '--Ck' do
+            let(:command) { super() + ['-C'] }
+            include_examples 'effective deploy'
+          end
+        end
+      end
+
+      context 'checks pass' do
+        before do
+          File.write(
+            'Checks',
+            "check :donkey do\n" \
+            "end\n" \
+            "\n" \
+            "deploy_check :donkey\n",
+          )
+        end
+
+        include_examples 'effective deploy'
+      end
+    end
+
+    describe 'listing deployers' do
+      shared_examples 'lists all deployers' do
+        let(:run) { Nanoc::CLI.run(command) }
+
+        it 'lists all deployers' do
+          expect { run }.to output(/Available deployers:\n  fog\n  rsync/).to_stdout
+        end
+
+        include_examples 'no effective deploy'
+      end
+
+      context '--list-deployers' do
+        let(:command) { %w(deploy --list-deployers) }
+        include_examples 'lists all deployers'
+      end
+
+      context '-D' do
+        let(:command) { %w(deploy -D) }
+        include_examples 'lists all deployers'
+      end
+    end
+
+    describe 'listing deployment configurations' do
+      shared_examples 'lists all deployment configurations' do
+        let(:run) { Nanoc::CLI.run(command) }
+
+        context 'no deployment configurations' do
+          let(:config) { { donkeys: 'lots' } }
+
+          it 'says nothing is found' do
+            expect { run }.to output(/No deployment configurations./).to_stdout
+          end
+
+          include_examples 'no effective deploy'
+        end
+
+        context 'some deployment configurations' do
+          let(:config) do
+            {
+              deploy: {
+                production: {
+                  kind: 'rsync',
+                  dst: 'remote',
+                },
+                staging: {
+                  kind: 'rsync',
+                  dst: 'remote',
+                },
+              },
+            }
+          end
+
+          it 'says some targets are found' do
+            expect { run }.to output(/Available deployment configurations:\n  production\n  staging/).to_stdout
+          end
+
+          include_examples 'no effective deploy'
+        end
+      end
+
+      context '--list' do
+        let(:command) { %w(deploy --list) }
+        include_examples 'lists all deployment configurations'
+      end
+
+      context '-L' do
+        let(:command) { %w(deploy -L) }
+        include_examples 'lists all deployment configurations'
+      end
+    end
+
+    describe 'deploying' do
+      let(:run) { Nanoc::CLI.run(command) }
+      let(:command) { %w(deploy) }
+
+      before do
+        FileUtils.mkdir_p('output')
+        FileUtils.mkdir_p('remote')
+        File.write('output/success.txt', 'hurrah')
+      end
+
+      shared_examples 'missing kind warning' do
+        it 'warns about missing kind' do
+          expect { run }.to output(/Warning: The specified deploy target does not have a kind attribute. Assuming rsync./).to_stderr
+        end
+      end
+
+      context 'no deploy configs' do
+        it 'errors' do
+          expect { run }.to raise_error(
+            Nanoc::Int::Errors::GenericTrivial,
+            'The site has no deployment configurations.',
+          )
+        end
+
+        include_examples 'no effective deploy'
+
+        context 'configuration created in preprocessor' do
+          before do
+            File.write(
+              'Rules',
+              "preprocess do\n" \
+              "  @config[:deploy] = {\n" \
+              "    default: { dst: 'remote' },\n" \
+              "  }\n" \
+              "end\n\n" + File.read('Rules'),
+            )
+          end
+
+          include_examples 'attempted/effective deploy'
+        end
+      end
+
+      context 'some deploy configs' do
+        let(:config) do
+          {
+            deploy: {
+              irrelevant: {
+                kind: 'rsync',
+                dst: 'remote',
+              },
+            },
+          }
+        end
+
+        context 'default target' do
+          context 'requested deploy config does not exist' do
+            it 'errors' do
+              expect { run }.to raise_error(
+                Nanoc::Int::Errors::GenericTrivial,
+                'The site has no deployment configuration named `default`.',
+              )
+            end
+
+            include_examples 'no effective deploy'
+          end
+
+          context 'requested deploy config exists' do
+            let(:config) do
+              {
+                deploy: {
+                  default: {
+                    kind: 'rsync',
+                    dst: 'remote',
+                  },
+                },
+              }
+            end
+
+            include_examples 'attempted/effective deploy'
+
+            context 'dry run' do
+              let(:command) { super() + ['--dry-run'] }
+              include_examples 'no effective deploy'
+            end
+          end
+
+          context 'requested deploy config exists, but has no kind' do
+            let(:config) do
+              {
+                deploy: {
+                  default: {
+                    dst: 'remote',
+                  },
+                },
+              }
+            end
+
+            include_examples 'attempted/effective deploy'
+            include_examples 'missing kind warning'
+
+            context 'dry run' do
+              let(:command) { super() + ['--dry-run'] }
+              include_examples 'no effective deploy'
+            end
+          end
+        end
+
+        shared_examples 'deploy with non-default target' do
+          context 'requested deploy config does not exist' do
+            it 'errors' do
+              expect { run }.to raise_error(
+                Nanoc::Int::Errors::GenericTrivial,
+                'The site has no deployment configuration named `production`.',
+              )
+            end
+
+            include_examples 'no effective deploy'
+          end
+
+          context 'requested deploy config exists' do
+            let(:config) do
+              {
+                deploy: {
+                  production: {
+                    kind: 'rsync',
+                    dst: 'remote',
+                  },
+                },
+              }
+            end
+
+            include_examples 'attempted/effective deploy'
+
+            context 'dry run' do
+              let(:command) { (super() + ['--dry-run']) }
+              include_examples 'no effective deploy'
+            end
+          end
+
+          context 'requested deploy config exists, but has no kind' do
+            let(:config) do
+              {
+                deploy: {
+                  production: {
+                    dst: 'remote',
+                  },
+                },
+              }
+            end
+
+            include_examples 'attempted/effective deploy'
+            include_examples 'missing kind warning'
+
+            context 'dry run' do
+              let(:command) { (super() + ['--dry-run']) }
+              include_examples 'no effective deploy'
+            end
+          end
+        end
+
+        context 'non-default target, specified as argument' do
+          let(:command) { %w(deploy production) }
+          include_examples 'deploy with non-default target'
+        end
+
+        context 'non-default target, specified as option (--target)' do
+          let(:command) { %w(deploy --target production) }
+          include_examples 'deploy with non-default target'
+        end
+
+        context 'multiple targets specified' do
+          let(:command) { %w(deploy --target staging production) }
+
+          it 'errors' do
+            expect { run }.to raise_error(
+              Nanoc::Int::Errors::GenericTrivial,
+              'Only one deployment target can be specified on the command line.',
+            )
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/shell_spec.rb b/spec/nanoc/cli/commands/shell_spec.rb
new file mode 100644
index 0000000..a38aa55
--- /dev/null
+++ b/spec/nanoc/cli/commands/shell_spec.rb
@@ -0,0 +1,54 @@
+describe Nanoc::CLI::Commands::Shell, site: true, stdio: true do
+  describe '#run' do
+    before do
+      # Prevent double-loading
+      expect(Nanoc::CLI).to receive(:setup)
+    end
+
+    it 'can be invoked' do
+      context = Object.new
+      allow(Nanoc::Int::Context).to receive(:new).with(anything).and_return(context)
+      expect(context).to receive(:pry)
+
+      Nanoc::CLI.run(['shell'])
+    end
+  end
+
+  describe '#env_for_site' do
+    subject { described_class.env_for_site(site) }
+
+    before do
+      File.write('content/hello.md', 'Hello!')
+      File.write('layouts/default.erb', '<title>MY SITE!</title><%= yield %>')
+    end
+
+    let(:site) do
+      Nanoc::Int::SiteLoader.new.new_from_cwd
+    end
+
+    it 'returns views' do
+      expect(subject[:items]).to be_a(Nanoc::ItemCollectionWithRepsView)
+      expect(subject[:layouts]).to be_a(Nanoc::LayoutCollectionView)
+      expect(subject[:config]).to be_a(Nanoc::ConfigView)
+    end
+
+    it 'returns correct items' do
+      expect(subject[:items].size).to eq(1)
+      expect(subject[:items].first.identifier.to_s).to eq('/hello.md')
+    end
+
+    it 'returns correct layouts' do
+      expect(subject[:layouts].size).to eq(1)
+      expect(subject[:layouts].first.identifier.to_s).to eq('/default.erb')
+    end
+
+    it 'returns items with reps' do
+      expect(subject[:items].first.reps).not_to be_nil
+      expect(subject[:items].first.reps.first.name).to eq(:default)
+    end
+
+    it 'returns items with rep paths' do
+      expect(subject[:items].first.reps.first.path).to eq('/hello.md')
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/show_data_spec.rb b/spec/nanoc/cli/commands/show_data_spec.rb
new file mode 100644
index 0000000..0cf10d9
--- /dev/null
+++ b/spec/nanoc/cli/commands/show_data_spec.rb
@@ -0,0 +1,126 @@
+describe Nanoc::CLI::Commands::ShowData, stdio: true do
+  describe '#print_item_dependencies' do
+    subject { runner.send(:print_item_dependencies, items, dependency_store) }
+
+    let(:runner) do
+      described_class.new(options, arguments, command)
+    end
+
+    let(:options) { {} }
+    let(:arguments) { [] }
+    let(:command) { double(:command) }
+
+    let(:items) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |ic|
+        ic << item_about
+        ic << item_dog
+        ic << item_other
+      end
+    end
+
+    let(:item_about) { Nanoc::Int::Item.new('About Me', {}, '/about.md') }
+    let(:item_dog)   { Nanoc::Int::Item.new('About My Dog', {}, '/dog.md') }
+    let(:item_other) { Nanoc::Int::Item.new('Raw Data', {}, '/other.dat') }
+
+    let(:config) { Nanoc::Int::Configuration.new }
+
+    let(:dependency_store) do
+      Nanoc::Int::DependencyStore.new(objects)
+    end
+
+    let(:objects) do
+      items.to_a + layouts.to_a
+    end
+
+    let(:layouts) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |ic|
+      end
+    end
+
+    it 'prints a legend' do
+      expect { subject }.to output(%r{Item dependencies =+\n\nLegend:}).to_stdout
+    end
+
+    context 'no dependencies' do
+      it 'outputs no dependencies for /about.md' do
+        expect { subject }.to output(%r{^item /about.md depends on:\n  \(nothing\)$}m).to_stdout
+      end
+
+      it 'outputs no dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \(nothing\)$}m).to_stdout
+      end
+
+      it 'outputs no dependencies for /other.dat' do
+        expect { subject }.to output(%r{^item /other.dat depends on:\n  \(nothing\)$}m).to_stdout
+      end
+    end
+
+    context 'dependency (without props) from about to dog' do
+      before do
+        dependency_store.record_dependency(item_dog, item_about)
+      end
+
+      it 'outputs no dependencies for /about.md' do
+        expect { subject }.to output(%r{^item /about.md depends on:\n  \(nothing\)$}m).to_stdout
+      end
+
+      it 'outputs dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \[   item \] \(racp\) /about.md$}m).to_stdout
+      end
+
+      it 'outputs no dependencies for /other.dat' do
+        expect { subject }.to output(%r{^item /other.dat depends on:\n  \(nothing\)$}m).to_stdout
+      end
+    end
+
+    context 'dependency (with raw_content prop) from about to dog' do
+      before do
+        dependency_store.record_dependency(item_dog, item_about, raw_content: true)
+      end
+
+      it 'outputs dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \[   item \] \(r___\) /about.md$}m).to_stdout
+      end
+    end
+
+    context 'dependency (with attributes prop) from about to dog' do
+      before do
+        dependency_store.record_dependency(item_dog, item_about, attributes: true)
+      end
+
+      it 'outputs dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \[   item \] \(_a__\) /about.md$}m).to_stdout
+      end
+    end
+
+    context 'dependency (with compiled_content prop) from about to dog' do
+      before do
+        dependency_store.record_dependency(item_dog, item_about, compiled_content: true)
+      end
+
+      it 'outputs dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \[   item \] \(__c_\) /about.md$}m).to_stdout
+      end
+    end
+
+    context 'dependency (with path prop) from about to dog' do
+      before do
+        dependency_store.record_dependency(item_dog, item_about, path: true)
+      end
+
+      it 'outputs dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \[   item \] \(___p\) /about.md$}m).to_stdout
+      end
+    end
+
+    context 'dependency (with multiple props) from about to dog' do
+      before do
+        dependency_store.record_dependency(item_dog, item_about, attributes: true, raw_content: true)
+      end
+
+      it 'outputs dependencies for /dog.md' do
+        expect { subject }.to output(%r{^item /dog.md depends on:\n  \[   item \] \(ra__\) /about.md$}m).to_stdout
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/show_rules_spec.rb b/spec/nanoc/cli/commands/show_rules_spec.rb
new file mode 100644
index 0000000..a4f8121
--- /dev/null
+++ b/spec/nanoc/cli/commands/show_rules_spec.rb
@@ -0,0 +1,112 @@
+describe Nanoc::CLI::Commands::ShowRules, stdio: true do
+  describe '#run' do
+    subject { runner.run }
+
+    let(:runner) do
+      described_class.new(options, arguments, command).tap do |runner|
+        runner.site = site
+      end
+    end
+
+    let(:options) { {} }
+
+    let(:arguments) { [] }
+
+    let(:command) { double(:command) }
+
+    let(:site) do
+      double(
+        :site,
+        items: items,
+        layouts: layouts,
+        compiler: compiler,
+      )
+    end
+
+    let(:items) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |ic|
+        ic << Nanoc::Int::Item.new('About Me', {}, '/about.md')
+        ic << Nanoc::Int::Item.new('About My Dog', {}, '/dog.md')
+        ic << Nanoc::Int::Item.new('Raw Data', {}, '/other.dat')
+      end
+    end
+
+    let(:reps) do
+      Nanoc::Int::ItemRepRepo.new.tap do |reps|
+        reps << Nanoc::Int::ItemRep.new(items['/about.md'], :default)
+        reps << Nanoc::Int::ItemRep.new(items['/about.md'], :text)
+        reps << Nanoc::Int::ItemRep.new(items['/dog.md'], :default)
+        reps << Nanoc::Int::ItemRep.new(items['/dog.md'], :text)
+        reps << Nanoc::Int::ItemRep.new(items['/other.dat'], :default)
+      end
+    end
+
+    let(:layouts) do
+      Nanoc::Int::IdentifiableCollection.new(config).tap do |ic|
+        ic << Nanoc::Int::Layout.new('Default', {}, '/default.erb')
+        ic << Nanoc::Int::Layout.new('Article', {}, '/article.haml')
+        ic << Nanoc::Int::Layout.new('Other', {}, '/other.xyzzy')
+      end
+    end
+
+    let(:config) { Nanoc::Int::Configuration.new }
+
+    let(:action_provider) { double(:action_provider, rules_collection: rules_collection) }
+    let(:compiler) { double(:compiler, action_provider: action_provider, reps: reps) }
+
+    let(:rules_collection) do
+      Nanoc::RuleDSL::RulesCollection.new.tap do |rc|
+        rc.add_item_compilation_rule(
+          Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/dog.*'), :default, proc {}),
+        )
+        rc.add_item_compilation_rule(
+          Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/*.md'), :default, proc {}),
+        )
+        rc.add_item_compilation_rule(
+          Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/**/*'), :text, proc {}),
+        )
+
+        rc.layout_filter_mapping[Nanoc::Int::Pattern.from('/*.haml')] = [:haml, {}]
+        rc.layout_filter_mapping[Nanoc::Int::Pattern.from('/*.erb')] = [:erb, {}]
+      end
+    end
+
+    let(:expected_out) do
+      <<-EOS
+        \e[1m\e[33mItem /about.md\e[0m:
+          Rep default: /*.md
+          Rep text: /**/*
+
+        \e[1m\e[33mItem /dog.md\e[0m:
+          Rep default: /dog.*
+          Rep text: /**/*
+
+        \e[1m\e[33mItem /other.dat\e[0m:
+          Rep default: (none)
+
+        \e[1m\e[33mLayout /article.haml\e[0m:
+          /*.haml
+
+        \e[1m\e[33mLayout /default.erb\e[0m:
+          /*.erb
+
+        \e[1m\e[33mLayout /other.xyzzy\e[0m:
+          (none)
+
+      EOS
+        .gsub(/^ {8}/, '')
+    end
+
+    before do
+      expect(compiler).to receive(:build_reps).once
+    end
+
+    it 'writes item and layout rules to stdout' do
+      expect { subject }.to output(expected_out).to_stdout
+    end
+
+    it 'writes status informaion to stderr' do
+      expect { subject }.to output("Loading site… done\n").to_stderr
+    end
+  end
+end
diff --git a/spec/nanoc/cli/commands/view_spec.rb b/spec/nanoc/cli/commands/view_spec.rb
new file mode 100644
index 0000000..2b64b19
--- /dev/null
+++ b/spec/nanoc/cli/commands/view_spec.rb
@@ -0,0 +1,58 @@
+describe Nanoc::CLI::Commands::View, site: true, stdio: true do
+  describe '#run' do
+    def run_nanoc_cmd(cmd)
+      pid = fork { Nanoc::CLI.run(cmd) }
+
+      # Wait for server to start up
+      20.times do |i|
+        begin
+          Net::HTTP.get('127.0.0.1', '/', 50_385)
+        rescue Errno::ECONNREFUSED, Errno::ECONNRESET
+          sleep(0.1 * 1.2**i)
+          retry
+        end
+        break
+      end
+
+      yield
+    ensure
+      Process.kill('TERM', pid)
+    end
+
+    context 'default configuration' do
+      it 'serves /index.html as /' do
+        File.write('output/index.html', 'Hello there! Nanoc loves you! <3')
+        run_nanoc_cmd(['view', '--port', '50385']) do
+          expect(Net::HTTP.get('127.0.0.1', '/', 50_385)).to eql('Hello there! Nanoc loves you! <3')
+        end
+      end
+
+      it 'does not serve /index.xhtml as /' do
+        File.write('output/index.xhtml', 'Hello there! Nanoc loves you! <3')
+        run_nanoc_cmd(['view', '--port', '50385']) do
+          expect(Net::HTTP.get('127.0.0.1', '/', 50_385)).to eql("File not found: /\n")
+        end
+      end
+    end
+
+    context 'index_filenames including index.xhtml' do
+      before do
+        File.write('nanoc.yaml', 'index_filenames: [index.xhtml]')
+      end
+
+      it 'serves /index.xhtml as /' do
+        File.write('output/index.xhtml', 'Hello there! Nanoc loves you! <3')
+        run_nanoc_cmd(['view', '--port', '50385']) do
+          expect(Net::HTTP.get('127.0.0.1', '/', 50_385)).to eql('Hello there! Nanoc loves you! <3')
+        end
+      end
+    end
+
+    it 'does not serve other files as /' do
+      File.write('output/index.html666', 'Hello there! Nanoc loves you! <3')
+      run_nanoc_cmd(['view', '--port', '50385']) do
+        expect(Net::HTTP.get('127.0.0.1', '/', 50_385)).to eql("File not found: /\n")
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/data_sources/filesystem_spec.rb b/spec/nanoc/data_sources/filesystem_spec.rb
new file mode 100644
index 0000000..90c02e6
--- /dev/null
+++ b/spec/nanoc/data_sources/filesystem_spec.rb
@@ -0,0 +1,56 @@
+describe Nanoc::DataSources::Filesystem do
+  let(:data_source) { Nanoc::DataSources::Filesystem.new(site.config, nil, nil, params) }
+  let(:params) { {} }
+  let(:site) { Nanoc::Int::SiteLoader.new.new_empty }
+
+  before { Timecop.freeze(now) }
+  after { Timecop.return }
+
+  let(:now) { Time.local(2008, 1, 2, 14, 5, 0) }
+
+  describe '#load_objects' do
+    subject { data_source.send(:load_objects, 'foo', klass) }
+
+    let(:klass) { raise 'override me' }
+
+    context 'items' do
+      let(:klass) { Nanoc::Int::Item }
+
+      context 'no files' do
+        it 'loads nothing' do
+          expect(subject).to be_empty
+        end
+      end
+
+      context 'one regular file' do
+        before do
+          FileUtils.mkdir_p('foo')
+          File.write('foo/bar.html', "---\nnum: 1\n---\ntest 1")
+          FileUtils.touch('foo/bar.html', mtime: now)
+        end
+
+        let(:expected_attributes) do
+          {
+            content_filename: 'foo/bar.html',
+            extension: 'html',
+            filename: 'foo/bar.html',
+            meta_filename: nil,
+            mtime: now,
+            num: 1,
+          }
+        end
+
+        it 'loads that file' do
+          expect(subject.size).to eq(1)
+
+          expect(subject[0].content.string).to eq('test 1')
+          expect(subject[0].attributes).to eq(expected_attributes)
+          expect(subject[0].identifier).to eq(Nanoc::Identifier.new('/bar/'))
+          expect(subject[0].checksum_data).to be_nil
+          expect(subject[0].content_checksum_data).to eq('test 1')
+          expect(subject[0].attributes_checksum_data).to eq("num: 1\n")
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/deploying/fog_spec.rb b/spec/nanoc/deploying/fog_spec.rb
new file mode 100644
index 0000000..e6550c0
--- /dev/null
+++ b/spec/nanoc/deploying/fog_spec.rb
@@ -0,0 +1,193 @@
+describe Nanoc::Deploying::Deployers::Fog, stdio: true do
+  let(:deployer) do
+    Nanoc::Deploying::Deployers::Fog.new(
+      'output/',
+      config,
+      dry_run: is_dry_run,
+    )
+  end
+
+  let(:is_dry_run) { false }
+
+  let(:config) do
+    {
+      bucket: 'bucky',
+      provider: 'local',
+      local_root: 'remote',
+    }
+  end
+
+  before do
+    # create output
+    FileUtils.mkdir_p('output')
+    FileUtils.mkdir_p('output/etc')
+    File.open('output/woof', 'w') { |io| io.write 'I am a dog!' }
+    File.open('output/etc/meow', 'w') { |io| io.write 'I am a cat!' }
+
+    # create local cloud
+    FileUtils.mkdir_p('remote')
+  end
+
+  subject { deployer.run }
+
+  shared_examples 'no effective deploy' do
+    it 'does not modify remote' do
+      expect { subject }.not_to change { Dir['remote/**/*'].sort }
+    end
+  end
+
+  shared_examples 'effective deploy' do
+    it 'modifies remote' do
+      expect { subject }.to change { Dir['remote/**/*'].sort }
+        .to([
+          'remote/bucky',
+          'remote/bucky/etc',
+          'remote/bucky/etc/meow',
+          'remote/bucky/woof',
+        ])
+    end
+  end
+
+  context 'dry run' do
+    let(:is_dry_run) { true }
+
+    before do
+      FileUtils.mkdir_p('remote/bucky')
+      FileUtils.mkdir_p('remote/bucky/tiny')
+      File.write('remote/bucky/pig', 'oink?')
+      File.write('remote/bucky/tiny/piglet', 'little oink?')
+    end
+
+    include_examples 'no effective deploy'
+
+    context 'with CDN ID' do
+      let(:config) { super().merge(cdn_id: 'donkey-cdn') }
+
+      let(:cdn) { Object.new }
+      let(:distribution) { Object.new }
+
+      it 'does not actually invalidate' do
+        expect(::Fog::CDN).to receive(:new).with(provider: 'local', local_root: 'remote').and_return(cdn)
+        expect(cdn).to receive(:get_distribution).with('donkey-cdn').and_return(distribution)
+
+        subject
+      end
+    end
+  end
+
+  context 'effective run' do
+    include_examples 'effective deploy'
+
+    context 'custom path' do
+      context 'custom path ends with /' do
+        let(:config) do
+          super().merge(path: 'foo/')
+        end
+
+        it 'raises' do
+          expect { subject }.to raise_error('The path `foo/` is not supposed to have a trailing slash')
+        end
+      end
+
+      context 'custom path does not end with /' do
+        let(:config) do
+          super().merge(path: 'foo')
+        end
+
+        it 'modifies remote' do
+          expect { subject }.to change { Dir['remote/**/*'].sort }
+            .to([
+              'remote/bucky',
+              'remote/bucky/foo',
+              'remote/bucky/foo/etc',
+              'remote/bucky/foo/etc/meow',
+              'remote/bucky/foo/woof',
+            ])
+        end
+      end
+    end
+
+    context 'bucket already exists' do
+      before do
+        FileUtils.mkdir_p('remote/bucky')
+      end
+
+      include_examples 'effective deploy'
+    end
+
+    context 'remote contains stale file at root' do
+      before do
+        FileUtils.mkdir_p('remote/bucky')
+        File.write('remote/bucky/pig', 'oink?')
+      end
+
+      include_examples 'effective deploy'
+
+      it 'does not contain stale files' do
+        subject
+        expect(Dir['remote/**/*'].sort).not_to include('remote/bucky/pig')
+      end
+    end
+
+    context 'remote contains stale file in subdirectory' do
+      before do
+        FileUtils.mkdir_p('remote/bucky/secret')
+        File.write('remote/bucky/secret/pig', 'oink?')
+      end
+
+      include_examples 'effective deploy'
+
+      it 'does not contain stale files' do
+        subject
+        expect(Dir['remote/**/*'].sort).not_to include('remote/bucky/secret/pig')
+      end
+    end
+
+    context 'with CDN ID' do
+      let(:config) { super().merge(cdn_id: 'donkey-cdn') }
+
+      let(:cdn) { Object.new }
+      let(:distribution) { Object.new }
+
+      it 'invalidates' do
+        expect(::Fog::CDN).to receive(:new).with(provider: 'local', local_root: 'remote').and_return(cdn)
+        expect(cdn).to receive(:get_distribution).with('donkey-cdn').and_return(distribution)
+        expect(cdn).to receive(:post_invalidation).with(distribution, contain_exactly('etc/meow', 'woof'))
+
+        subject
+      end
+    end
+
+    context 'remote list consists of truncated sets' do
+      before do
+        expect(::Fog::Storage).to receive(:new).and_return(fog_storage)
+        expect(fog_storage).to receive(:directories).and_return(directories)
+        expect(directories).to receive(:get).and_return(directory)
+        allow(directory).to receive(:files).and_return(files)
+        expect(files).to receive(:get).with('stray').and_return(file_stray).ordered
+        expect(files).to receive(:each)
+          .and_yield(double(:woof, key: 'woof'))
+          .and_yield(double(:meow, key: 'etc/meow'))
+          .and_yield(double(:stray, key: 'stray'))
+        expect(file_stray).to receive(:destroy)
+
+        expect(files).to receive(:create).with(key: 'woof', body: anything, public: true) do
+          FileUtils.mkdir_p('remote/bucky')
+          File.write('remote/bucky/woof', 'hi')
+        end
+        expect(files).to receive(:create).with(key: 'etc/meow', body: anything, public: true) do
+          FileUtils.mkdir_p('remote/bucky/etc')
+          File.write('remote/bucky/etc/meow', 'hi')
+        end
+      end
+
+      let(:fog_storage) { double(:fog_storage) }
+      let(:directories) { double(:directories) }
+      let(:directory) { double(:directory) }
+      let(:files) { double(:files) }
+      let(:file_stray) { double(:file_stray) }
+
+      include_examples 'effective deploy'
+    end
+  end
+end
diff --git a/spec/nanoc/extra/parallel_collection_spec.rb b/spec/nanoc/extra/parallel_collection_spec.rb
new file mode 100644
index 0000000..4674093
--- /dev/null
+++ b/spec/nanoc/extra/parallel_collection_spec.rb
@@ -0,0 +1,108 @@
+describe Nanoc::Extra::ParallelCollection do
+  subject(:col) { described_class.new(wrapped, parallelism: parallelism) }
+  let(:wrapped) { [1, 2, 3, 4, 5] }
+  let(:parallelism) { 5 }
+
+  describe '#each' do
+    subject do
+      col.each do |e|
+        sleep 0.1
+        out << e
+      end
+    end
+    let!(:out) { [] }
+
+    it 'is fast' do
+      expect { subject }.to finish_in_under(0.2).seconds
+    end
+
+    it 'is correct' do
+      expect { subject }.to change { out.sort }.from([]).to([1, 2, 3, 4, 5])
+    end
+
+    it 'does not leave threads lingering' do
+      expect { subject }.not_to change { Thread.list.size }
+    end
+
+    context 'errors' do
+      subject do
+        col.each do |e|
+          if e == 1
+            sleep 0.02
+            raise 'ugh'
+          else
+            sleep 0.1
+            out << e
+          end
+        end
+      end
+
+      let(:parallelism) { 3 }
+
+      it 'raises' do
+        expect { subject }.to raise_error(RuntimeError, 'ugh')
+      end
+
+      it 'aborts early' do
+        expect { subject rescue nil }.to change { out.sort }.from([]).to([2, 3])
+      end
+    end
+
+    context 'low parallelism' do
+      let(:parallelism) { 1 }
+
+      it 'is not fast' do
+        expect { subject }.not_to finish_in_under(0.5).seconds
+      end
+    end
+  end
+
+  describe '#map' do
+    subject do
+      col.map do |e|
+        sleep 0.1
+        e * 10
+      end
+    end
+
+    it 'is fast' do
+      expect { subject }.to finish_in_under(0.2).seconds
+    end
+
+    it 'does not leave threads lingering' do
+      expect { subject }.not_to change { Thread.list.size }
+    end
+
+    it 'is correct' do
+      expect(subject.sort).to eq([10, 20, 30, 40, 50])
+    end
+
+    context 'errors' do
+      subject do
+        col.each do |e|
+          if e == 1
+            sleep 0.02
+            raise 'ugh'
+          else
+            sleep 0.1
+            e * 10
+          end
+        end
+      end
+
+      let(:parallelism) { 3 }
+
+      it 'raises' do
+        expect { subject }.to raise_error(RuntimeError, 'ugh')
+      end
+    end
+
+    context 'low parallelism' do
+      let(:parallelism) { 1 }
+
+      it 'is not fast' do
+        expect { subject }.not_to finish_in_under(0.5).seconds
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/filters/colorize_syntax/rouge_spec.rb b/spec/nanoc/filters/colorize_syntax/rouge_spec.rb
new file mode 100644
index 0000000..1026fcf
--- /dev/null
+++ b/spec/nanoc/filters/colorize_syntax/rouge_spec.rb
@@ -0,0 +1,195 @@
+require 'rouge'
+
+describe Nanoc::Filters::ColorizeSyntax, filter: true, rouge: true do
+  subject { filter.setup_and_run(input, default_colorizer: :rouge, rouge: params) }
+  let(:filter) { ::Nanoc::Filters::ColorizeSyntax.new }
+  let(:params) { {} }
+  let(:wrap) { false }
+  let(:css_class) { 'highlight' }
+  let(:input) do
+    <<-EOS.freeze
+before
+<pre><code class="language-ruby">
+  def foo
+  end
+</code></pre>
+after
+    EOS
+  end
+  let(:output) do
+    <<-EOS
+before
+<pre><code class=\"language-ruby#{wrap ? " #{css_class}" : ''}\">  <span class=\"k\">def</span> <span class=\"nf\">foo</span>
+  <span class=\"k\">end</span></code></pre>
+after
+    EOS
+  end
+
+  context 'with Rouge' do
+    context 'with 1.x', if: Rouge.version < '2' do
+      context 'with default options' do
+        it { is_expected.to eql output }
+      end
+
+      context 'with pygments wrapper' do
+        let(:wrap) { true }
+        let(:params) { super().merge(wrap: wrap) }
+
+        it { is_expected.to eql output }
+
+        context 'with css_class' do
+          let(:css_class) { 'nanoc' }
+          let(:params) { super().merge(css_class: css_class) }
+
+          it { is_expected.to eql output }
+        end
+      end
+
+      context 'with line number' do
+        let(:line_numbers) { true }
+        let(:params) { super().merge(line_numbers: line_numbers) }
+        let(:output) do
+          <<-EOS
+before
+<pre><code class="language-ruby"><table style="border-spacing: 0"><tbody><tr>
+<td class="gutter gl" style="text-align: right"><pre class="lineno">1
+2</pre></td>
+<td class="code"><pre>  <span class="k">def</span> <span class="nf">foo</span>
+  <span class="k">end</span><span class="w">
+</span></pre></td>
+</tr></tbody></table></code></pre>
+after
+          EOS
+        end
+
+        it { is_expected.to eql output }
+      end
+    end
+
+    context 'with 2.x', if: Rouge.version >= '2' do
+      context 'with default options' do
+        it { is_expected.to eql output }
+      end
+
+      context 'with legacy' do
+        let(:legacy) { true }
+        let(:params) { super().merge(legacy: legacy) }
+
+        it { is_expected.to eql output }
+
+        context 'with pygments wrapper' do
+          let(:wrap) { true }
+          let(:params) { super().merge(wrap: wrap) }
+
+          it { is_expected.to eql output }
+
+          context 'with css_class' do
+            let(:css_class) { 'nanoc' }
+            let(:params) { super().merge(css_class: css_class) }
+
+            it { is_expected.to eql output }
+          end
+        end
+
+        context 'with line number' do
+          let(:line_numbers) { true }
+          let(:params) { super().merge(line_numbers: line_numbers) }
+          let(:output) do
+            <<-EOS
+before
+<pre><code class="language-ruby"><table class="rouge-table"><tbody><tr>
+<td class="rouge-gutter gl"><pre class="lineno">1
+2
+</pre></td>
+<td class="rouge-code"><pre>  <span class="k">def</span> <span class="nf">foo</span>
+  <span class="k">end</span></pre></td>
+</tr></tbody></table></code></pre>
+after
+            EOS
+          end
+
+          it { is_expected.to eql output }
+        end
+      end
+
+      context 'with formater' do
+        let(:params) { super().merge(formatter: formatter) }
+
+        context 'with inline' do
+          let(:formatter) { Rouge::Formatters::HTMLInline.new(theme) }
+
+          context 'with github theme' do
+            let(:theme) { Rouge::Themes::Github.new }
+            let(:output) do
+              <<-EOS
+before
+<pre><code class="language-ruby">  <span style="color: #000000;font-weight: bold">def</span> <span style="color: #990000;font-weight: bold">foo</span>
+  <span style="color: #000000;font-weight: bold">end</span></code></pre>
+after
+              EOS
+            end
+
+            it { is_expected.to eql output }
+          end
+
+          context 'with colorful theme' do
+            let(:theme) { Rouge::Themes::Colorful.new }
+            let(:output) do
+              <<-EOS
+before
+<pre><code class="language-ruby">  <span style="color: #080;font-weight: bold">def</span> <span style="color: #06B;font-weight: bold">foo</span>
+  <span style="color: #080;font-weight: bold">end</span></code></pre>
+after
+              EOS
+            end
+
+            it { is_expected.to eql output }
+          end
+        end
+
+        context 'with linewise' do
+          let(:formatter) { Rouge::Formatters::HTMLLinewise.new(Rouge::Formatters::HTML.new) }
+          let(:output) do
+            <<-EOS
+before
+<pre><code class="language-ruby"><div class="line-1">  <span class="k">def</span> <span class="nf">foo</span>
+</div>
+<div class="line-2">  <span class="k">end</span>
+</div></code></pre>
+after
+            EOS
+          end
+
+          it { is_expected.to eql output }
+        end
+
+        context 'with pygments' do
+          let(:wrap) { true }
+          let(:css_class) { 'codehilite' }
+          let(:formatter) { Rouge::Formatters::HTMLPygments.new(Rouge::Formatters::HTML.new) }
+
+          it { is_expected.to eql output }
+        end
+
+        context 'with table' do
+          let(:formatter) { Rouge::Formatters::HTMLTable.new(Rouge::Formatters::HTML.new) }
+          let(:output) do
+            <<-EOS
+before
+<pre><code class="language-ruby"><table class="rouge-table"><tbody><tr>
+<td class="rouge-gutter gl"><pre class="lineno">1
+2
+</pre></td>
+<td class="rouge-code"><pre>  <span class="k">def</span> <span class="nf">foo</span>
+  <span class="k">end</span></pre></td>
+</tr></tbody></table></code></pre>
+after
+            EOS
+          end
+
+          it { is_expected.to eql output }
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/filters/less_spec.rb b/spec/nanoc/filters/less_spec.rb
new file mode 100644
index 0000000..9c28b81
--- /dev/null
+++ b/spec/nanoc/filters/less_spec.rb
@@ -0,0 +1,120 @@
+describe Nanoc::Filters::Less, site: true, stdio: true, v8: true do
+  # These tests are high-level in order to interact well with the compiler. This is important for
+  # this :less filter, because of the way it handles fibers.
+
+  before do
+    File.open('Rules', 'w') do |io|
+      io.write "compile '/**/*.less' do\n"
+      io.write "  filter :less\n"
+      io.write "  write item.identifier.without_ext + '.css'\n"
+      io.write "end\n"
+    end
+  end
+
+  context 'one file' do
+    let(:content_a) { 'p { color: red; }' }
+
+    before do
+      File.write('content/a.less', content_a)
+    end
+
+    it 'compiles a.less' do
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/a.css')).to match(/^p\s*\{\s*color:\s*red;?\s*\}/)
+    end
+
+    context 'with compression' do
+      let(:content_a) { '.foo { bar: a; } .bar { foo: b; }' }
+
+      before do
+        File.open('Rules', 'w') do |io|
+          io.write "compile '/*.less' do\n"
+          io.write "  filter :less, compress: true\n"
+          io.write "  write item.identifier.without_ext + '.css'\n"
+          io.write "end\n"
+        end
+      end
+
+      it 'compiles and compresses a.less' do
+        Nanoc::CLI.run(%w(compile))
+        expect(File.read('output/a.css')).to match(/^\.foo\{bar:a\}\n?\.bar\{foo:b\}/)
+      end
+    end
+  end
+
+  context 'two files' do
+    let(:content_a) { '@import "b.less";' }
+    let(:content_b) { 'p { color: red; }' }
+
+    before do
+      File.write('content/a.less', content_a)
+      File.write('content/b.less', content_b)
+    end
+
+    it 'compiles a.less' do
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/a.css')).to match(/^p\s*\{\s*color:\s*red;?\s*\}/)
+    end
+
+    it 'recompiles a.less if b.less has changed' do
+      Nanoc::CLI.run(%w(compile))
+
+      File.write('content/b.less', 'p { color: blue; }')
+
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/a.css')).to match(/^p\s*\{\s*color:\s*blue;?\s*\}/)
+    end
+  end
+
+  context 'paths relative to site directory' do
+    let(:content_a) { '@import "content/foo/bar/imported_file.less";' }
+    let(:content_b) { 'p { color: red; }' }
+
+    before do
+      FileUtils.mkdir_p('content/foo/bar')
+
+      File.write('content/a.less', content_a)
+      File.write('content/foo/bar/imported_file.less', content_b)
+    end
+
+    it 'compiles a.less' do
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/a.css')).to match(/^p\s*\{\s*color:\s*red;?\s*\}/)
+    end
+
+    it 'recompiles a.less if b.less has changed' do
+      Nanoc::CLI.run(%w(compile))
+
+      File.write('content/foo/bar/imported_file.less', 'p { color: blue; }')
+
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/a.css')).to match(/^p\s*\{\s*color:\s*blue;?\s*\}/)
+    end
+  end
+
+  context 'paths relative to current file' do
+    let(:content_a) { '@import "bar/imported_file.less";' }
+    let(:content_b) { 'p { color: red; }' }
+
+    before do
+      FileUtils.mkdir_p('content/foo/bar')
+
+      File.write('content/foo/a.less', content_a)
+      File.write('content/foo/bar/imported_file.less', content_b)
+    end
+
+    it 'compiles a.less' do
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/foo/a.css')).to match(/^p\s*\{\s*color:\s*red;?\s*\}/)
+    end
+
+    it 'recompiles a.less if b.less has changed' do
+      Nanoc::CLI.run(%w(compile))
+
+      File.write('content/foo/bar/imported_file.less', 'p { color: blue; }')
+
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/foo/a.css')).to match(/^p\s*\{\s*color:\s*blue;?\s*\}/)
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/blogging_spec.rb b/spec/nanoc/helpers/blogging_spec.rb
new file mode 100644
index 0000000..ca24616
--- /dev/null
+++ b/spec/nanoc/helpers/blogging_spec.rb
@@ -0,0 +1,216 @@
+describe Nanoc::Helpers::Blogging, helper: true do
+  before do
+    allow(ctx.dependency_tracker).to receive(:enter)
+    allow(ctx.dependency_tracker).to receive(:exit)
+  end
+
+  describe '#articles' do
+    subject { helper.articles }
+
+    before do
+      ctx.create_item('blah', { kind: 'item' }, '/0/')
+      ctx.create_item('blah blah', { kind: 'article' }, '/1/')
+      ctx.create_item('blah blah blah', { kind: 'article' }, '/2/')
+    end
+
+    it 'returns the two articles' do
+      expect(subject.map(&:identifier)).to match_array(['/1/', '/2/'])
+    end
+  end
+
+  describe '#sorted_articles' do
+    subject { helper.sorted_articles }
+
+    before do
+      attrs = { kind: 'item' }
+      ctx.create_item('blah', attrs, '/0/')
+
+      attrs = { kind: 'article', created_at: (Date.today - 1).to_s }
+      ctx.create_item('blah blah', attrs, '/1/')
+
+      attrs = { kind: 'article', created_at: (Time.now - 500).to_s }
+      ctx.create_item('blah blah blah', attrs, '/2/')
+    end
+
+    it 'returns the two articles in descending order' do
+      expect(subject.map(&:identifier)).to eq(['/2/', '/1/'])
+    end
+  end
+
+  describe '#url_for' do
+    subject { helper.url_for(ctx.items['/stuff/']) }
+
+    let(:item_attributes) { {} }
+
+    before do
+      item = ctx.create_item('Stuff', item_attributes, '/stuff/')
+      ctx.create_rep(item, '/rep/path/stuff.html')
+
+      ctx.config[:base_url] = base_url
+    end
+
+    context 'without base_url' do
+      let(:base_url) { nil }
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Error)
+      end
+    end
+
+    context 'with base_url' do
+      let(:base_url) { 'http://url.base' }
+
+      context 'with custom_url_in_feed' do
+        let(:item_attributes) do
+          { custom_url_in_feed: 'http://example.com/stuff.html' }
+        end
+
+        it 'returns custom URL' do
+          expect(subject).to eql('http://example.com/stuff.html')
+        end
+      end
+
+      context 'without custom_url_in_feed' do
+        context 'with custom_path_in_feed' do
+          let(:item_attributes) do
+            { custom_path_in_feed: '/stuff.html' }
+          end
+
+          it 'returns base URL + custom path' do
+            expect(subject).to eql('http://url.base/stuff.html')
+          end
+        end
+
+        context 'without custom_path_in_feed' do
+          it 'returns base URL + path' do
+            expect(subject).to eql('http://url.base/rep/path/stuff.html')
+          end
+        end
+      end
+    end
+  end
+
+  describe '#feed_url' do
+    subject { helper.feed_url }
+
+    let(:item_attributes) { {} }
+
+    before do
+      ctx.item = ctx.create_item('Feed', item_attributes, '/feed/')
+      ctx.create_rep(ctx.item, '/feed.xml')
+
+      ctx.config[:base_url] = base_url
+    end
+
+    context 'without base_url' do
+      let(:base_url) { nil }
+
+      it 'raises' do
+        expect { subject }.to raise_error(Nanoc::Error)
+      end
+    end
+
+    context 'with base_url' do
+      let(:base_url) { 'http://url.base' }
+
+      context 'with feed_url' do
+        let(:item_attributes) do
+          { feed_url: 'http://custom.feed.url/feed.rss' }
+        end
+
+        it 'returns custom URL' do
+          expect(subject).to eql('http://custom.feed.url/feed.rss')
+        end
+      end
+
+      context 'without feed_url' do
+        it 'returns base URL + path' do
+          expect(subject).to eql('http://url.base/feed.xml')
+        end
+      end
+    end
+  end
+
+  describe '#attribute_to_time' do
+    subject { helper.attribute_to_time(arg) }
+
+    let(:noon_s) { 1_446_903_076 }
+    let(:beginning_of_day_s) { 1_446_854_400 }
+
+    let(:around_noon_local) { Time.at(noon_s - Time.at(noon_s).utc_offset) }
+    let(:around_noon_utc) { Time.at(noon_s) }
+    let(:beginning_of_day_utc) { Time.at(beginning_of_day_s) }
+
+    context 'with Time instance' do
+      let(:arg) { around_noon_utc }
+      it { is_expected.to eql(around_noon_utc) }
+    end
+
+    context 'with Date instance' do
+      let(:arg) { Date.new(2015, 11, 7) }
+      it { is_expected.to eql(beginning_of_day_utc) }
+    end
+
+    context 'with DateTime instance' do
+      let(:arg) { DateTime.new(2015, 11, 7, 13, 31, 16) }
+      it { is_expected.to eql(around_noon_utc) }
+    end
+
+    context 'with string' do
+      let(:arg) { '2015-11-7 13:31:16' }
+      it { is_expected.to eql(around_noon_local) }
+    end
+  end
+
+  describe '#atom_tag_for' do
+    subject { helper.atom_tag_for(ctx.items['/stuff/']) }
+
+    let(:item_attributes) { { created_at: '2015-05-19 12:34:56' } }
+    let(:item_rep_path) { '/stuff.xml' }
+    let(:base_url) { 'http://url.base' }
+
+    before do
+      item = ctx.create_item('Stuff', item_attributes, '/stuff/')
+      ctx.create_rep(item, item_rep_path)
+
+      ctx.config[:base_url] = base_url
+    end
+
+    context 'item with path' do
+      let(:item_rep_path) { '/stuff.xml' }
+      it { is_expected.to eql('tag:url.base,2015-05-19:/stuff.xml') }
+    end
+
+    context 'item without path' do
+      let(:item_rep_path) { nil }
+      it { is_expected.to eql('tag:url.base,2015-05-19:/stuff/') }
+    end
+
+    context 'bare URL without subdir' do
+      let(:base_url) { 'http://url.base' }
+      it { is_expected.to eql('tag:url.base,2015-05-19:/stuff.xml') }
+    end
+
+    context 'bare URL with subdir' do
+      let(:base_url) { 'http://url.base/sub' }
+      it { is_expected.to eql('tag:url.base,2015-05-19:/sub/stuff.xml') }
+    end
+
+    context 'created_at is date' do
+      let(:item_attributes) do
+        { created_at: Date.parse('2015-05-19 12:34:56') }
+      end
+      it { is_expected.to eql('tag:url.base,2015-05-19:/stuff.xml') }
+    end
+
+    context 'created_at is time' do
+      let(:item_attributes) do
+        { created_at: Time.parse('2015-05-19 12:34:56') }
+      end
+      it { is_expected.to eql('tag:url.base,2015-05-19:/stuff.xml') }
+    end
+
+    # TODO: handle missing base_dir
+    # TODO: handle missing created_at
+  end
+end
diff --git a/spec/nanoc/helpers/breadcrumbs_spec.rb b/spec/nanoc/helpers/breadcrumbs_spec.rb
new file mode 100644
index 0000000..d9d439c
--- /dev/null
+++ b/spec/nanoc/helpers/breadcrumbs_spec.rb
@@ -0,0 +1,133 @@
+describe Nanoc::Helpers::Breadcrumbs, helper: true do
+  before do
+    allow(ctx.dependency_tracker).to receive(:enter)
+    allow(ctx.dependency_tracker).to receive(:exit)
+  end
+
+  describe '#breadcrumbs_trail' do
+    subject { helper.breadcrumbs_trail }
+
+    context 'legacy identifiers' do
+      context 'root' do
+        before do
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/', type: :legacy))
+
+          ctx.item = ctx.items['/']
+        end
+
+        it 'returns an array with the item' do
+          expect(subject).to eql([ctx.items['/']])
+        end
+      end
+
+      context 'root and direct child' do
+        before do
+          ctx.create_item('child', {}, Nanoc::Identifier.new('/foo/', type: :legacy))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/', type: :legacy))
+
+          ctx.item = ctx.items['/foo/']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/'], ctx.items['/foo/']])
+        end
+      end
+
+      context 'root, child and grandchild' do
+        before do
+          ctx.create_item('grandchild', {}, Nanoc::Identifier.new('/foo/bar/', type: :legacy))
+          ctx.create_item('child', {}, Nanoc::Identifier.new('/foo/', type: :legacy))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/', type: :legacy))
+
+          ctx.item = ctx.items['/foo/bar/']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/'], ctx.items['/foo/'], ctx.items['/foo/bar/']])
+        end
+      end
+
+      context 'root, missing child and grandchild' do
+        before do
+          ctx.create_item('grandchild', {}, Nanoc::Identifier.new('/foo/bar/', type: :legacy))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/', type: :legacy))
+
+          ctx.item = ctx.items['/foo/bar/']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/'], nil, ctx.items['/foo/bar/']])
+        end
+      end
+    end
+
+    context 'non-legacy identifiers' do
+      context 'root' do
+        before do
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/index.md'))
+
+          ctx.item = ctx.items['/index.md']
+        end
+
+        it 'returns an array with the item' do
+          expect(subject).to eql([ctx.items['/index.md']])
+        end
+      end
+
+      context 'root and direct child' do
+        before do
+          ctx.create_item('child', {}, Nanoc::Identifier.new('/foo.md'))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/index.md'))
+
+          ctx.item = ctx.items['/foo.md']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/index.md'], ctx.items['/foo.md']])
+        end
+      end
+
+      context 'root, child and grandchild' do
+        before do
+          ctx.create_item('grandchild', {}, Nanoc::Identifier.new('/foo/bar.md'))
+          ctx.create_item('child', {}, Nanoc::Identifier.new('/foo.md'))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/index.md'))
+
+          ctx.item = ctx.items['/foo/bar.md']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/index.md'], ctx.items['/foo.md'], ctx.items['/foo/bar.md']])
+        end
+      end
+
+      context 'root, missing child and grandchild' do
+        before do
+          ctx.create_item('grandchild', {}, Nanoc::Identifier.new('/foo/bar.md'))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/index.md'))
+
+          ctx.item = ctx.items['/foo/bar.md']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/index.md'], nil, ctx.items['/foo/bar.md']])
+        end
+      end
+
+      context 'index.md child' do
+        # No special handling of non-root index.* files.
+
+        before do
+          ctx.create_item('grandchild', {}, Nanoc::Identifier.new('/foo/index.md'))
+          ctx.create_item('root', {}, Nanoc::Identifier.new('/index.md'))
+
+          ctx.item = ctx.items['/foo/index.md']
+        end
+
+        it 'returns an array with the items' do
+          expect(subject).to eql([ctx.items['/index.md'], nil, ctx.items['/foo/index.md']])
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/capturing_spec.rb b/spec/nanoc/helpers/capturing_spec.rb
new file mode 100644
index 0000000..4f5d20e
--- /dev/null
+++ b/spec/nanoc/helpers/capturing_spec.rb
@@ -0,0 +1,181 @@
+describe Nanoc::Helpers::Capturing, helper: true do
+  describe '#content_for' do
+    before do
+      ctx.item = ctx.create_item('some content', {}, '/about.md')
+      ctx.create_rep(ctx.item, '/about.html')
+    end
+
+    describe 'with name + block' do
+      let(:_erbout) { 'existing content' }
+
+      context 'only name given' do
+        subject { helper.content_for(:foo) { _erbout << 'foo' } }
+
+        it 'stores snapshot content' do
+          subject
+          expect(ctx.item.reps[:default].unwrap.snapshot_contents[:__capture_foo].string).to eql('foo')
+        end
+      end
+
+      context 'name and params given' do
+        subject { helper.content_for(:foo, params) { _erbout << 'foo' } }
+        let(:params) { raise 'overwrite me' }
+
+        context 'no existing behavior specified' do
+          let(:params) { {} }
+
+          it 'errors after two times' do
+            helper.content_for(:foo, params) { _erbout << 'foo' }
+            expect { helper.content_for(:foo, params) { _erbout << 'bar' } }.to raise_error(RuntimeError)
+          end
+        end
+
+        context 'existing behavior is :overwrite' do
+          let(:params) { { existing: :overwrite } }
+
+          it 'overwrites' do
+            helper.content_for(:foo, params) { _erbout << 'foo' }
+            helper.content_for(:foo, params) { _erbout << 'bar' }
+            expect(ctx.item.reps[:default].unwrap.snapshot_contents[:__capture_foo].string).to eql('bar')
+          end
+        end
+
+        context 'existing behavior is :append' do
+          let(:params) { { existing: :append } }
+
+          it 'appends' do
+            helper.content_for(:foo, params) { _erbout << 'foo' }
+            helper.content_for(:foo, params) { _erbout << 'bar' }
+            expect(ctx.item.reps[:default].unwrap.snapshot_contents[:__capture_foo].string).to eql('foobar')
+          end
+        end
+
+        context 'existing behavior is :error' do
+          let(:params) { { existing: :error } }
+
+          it 'errors after two times' do
+            helper.content_for(:foo, params) { _erbout << 'foo' }
+            expect { helper.content_for(:foo, params) { _erbout << 'bar' } }.to raise_error(RuntimeError)
+          end
+        end
+
+        context 'existing behavior is :something else' do
+          let(:params) { { existing: :donkey } }
+
+          it 'errors' do
+            expect { subject }.to raise_error(ArgumentError)
+          end
+        end
+      end
+    end
+
+    describe 'with item + name' do
+      subject { helper.content_for(item, :foo) }
+
+      let(:_erbout) { 'existing content' }
+
+      context 'requesting for same item' do
+        let(:item) { ctx.item }
+
+        context 'nothing captured' do
+          it { is_expected.to be_nil }
+        end
+
+        context 'something captured' do
+          before do
+            helper.content_for(:foo) { _erbout << 'I have been captured!' }
+          end
+
+          it { is_expected.to eql('I have been captured!') }
+        end
+      end
+
+      context 'requesting for other item' do
+        let(:item) { ctx.items['/other.md'] }
+
+        before do
+          item = ctx.create_item('other content', {}, '/other.md')
+          ctx.create_rep(item, '/other.html')
+        end
+
+        context 'other item is not yet compiled' do
+          it 'raises an unmet dependency error' do
+            expect(ctx.dependency_tracker).to receive(:bounce).with(item.unwrap, compiled_content: true)
+            expect { subject }.to raise_error(FiberError)
+          end
+        end
+
+        context 'other item is compiled' do
+          before do
+            item.reps[:default].unwrap.compiled = true
+            item.reps[:default].unwrap.snapshot_contents[:__capture_foo] =
+              Nanoc::Int::TextualContent.new('other captured foo')
+          end
+
+          it 'returns the captured content' do
+            expect(ctx.dependency_tracker).to receive(:bounce).with(item.unwrap, compiled_content: true)
+            expect(subject).to eql('other captured foo')
+          end
+        end
+      end
+    end
+  end
+
+  describe '#capture' do
+    context 'with string' do
+      let(:_erbout) { 'existing content' }
+
+      subject { helper.capture { _erbout << 'new content' } }
+
+      it 'returns the appended content' do
+        expect(subject).to eql('new content')
+      end
+
+      it 'does not modify _erbout' do
+        expect { subject }.not_to change { _erbout }
+      end
+    end
+
+    context 'with array' do
+      let(:_erbout) { ['existing content'] }
+
+      shared_examples 'returns properly joined output' do
+        subject { helper.capture { _erbout << %w(new _ content) } }
+
+        it 'returns the appended content, joined' do
+          expect(subject).to eql('new_content')
+        end
+
+        it 'does not modify _erbout' do
+          expect { subject }.not_to change { _erbout.join('') }
+        end
+      end
+
+      context 'default output field separator' do
+        include_examples 'returns properly joined output'
+      end
+
+      context 'output field separator set to ,' do
+        around do |ex|
+          orig_output_field_separator = $OUTPUT_FIELD_SEPARATOR
+          $OUTPUT_FIELD_SEPARATOR = ','
+          ex.run
+          $OUTPUT_FIELD_SEPARATOR = orig_output_field_separator
+        end
+
+        include_examples 'returns properly joined output'
+      end
+
+      context 'output field separator set to nothing' do
+        around do |ex|
+          orig_output_field_separator = $OUTPUT_FIELD_SEPARATOR
+          $OUTPUT_FIELD_SEPARATOR = ''
+          ex.run
+          $OUTPUT_FIELD_SEPARATOR = orig_output_field_separator
+        end
+
+        include_examples 'returns properly joined output'
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/child_parent_spec.rb b/spec/nanoc/helpers/child_parent_spec.rb
new file mode 100644
index 0000000..b7fdf9b
--- /dev/null
+++ b/spec/nanoc/helpers/child_parent_spec.rb
@@ -0,0 +1,105 @@
+describe Nanoc::Helpers::ChildParent, helper: true do
+  describe '#children_of' do
+    subject { helper.children_of(item) }
+
+    let(:item) { ctx.create_item('some content', {}, identifier) }
+
+    context 'legacy identifier' do
+      let(:identifier) { Nanoc::Identifier.new('/foo/', type: :legacy) }
+
+      let!(:child_item) do
+        ctx.create_item('abc', {}, Nanoc::Identifier.new('/foo/a/', type: :legacy))
+      end
+
+      let!(:grandchild_item) do
+        ctx.create_item('def', {}, Nanoc::Identifier.new('/foo/a/b/', type: :legacy))
+      end
+
+      let!(:sibling_item) do
+        ctx.create_item('xyz', {}, Nanoc::Identifier.new('/bar/', type: :legacy))
+      end
+
+      it 'returns only direct children' do
+        expect(subject).to eql([child_item])
+      end
+    end
+
+    context 'full identifier' do
+      let(:identifier) { Nanoc::Identifier.new('/foo.md', type: :full) }
+
+      let!(:child_item) do
+        ctx.create_item('abc', {}, Nanoc::Identifier.new('/foo/a.md', type: :full))
+      end
+
+      let!(:grandchild_item) do
+        ctx.create_item('def', {}, Nanoc::Identifier.new('/foo/a/b.md', type: :full))
+      end
+
+      let!(:sibling_item) do
+        ctx.create_item('xyz', {}, Nanoc::Identifier.new('/bar.md', type: :full))
+      end
+
+      let!(:index_child_item) do
+        ctx.create_item('xyz', {}, Nanoc::Identifier.new('/foo/a/index.md', type: :full))
+      end
+
+      it 'returns only direct children' do
+        expect(subject).to eql([child_item])
+      end
+    end
+  end
+
+  describe '#parent_of' do
+    subject { helper.parent_of(item) }
+
+    let(:item) { ctx.create_item('some content', {}, identifier) }
+
+    context 'legacy identifier' do
+      let(:identifier) { Nanoc::Identifier.new('/foo/bar/', type: :legacy) }
+
+      let!(:parent_item) do
+        ctx.create_item('abc', {}, Nanoc::Identifier.new('/foo/', type: :legacy))
+      end
+
+      let!(:sibling_item) do
+        ctx.create_item('def', {}, Nanoc::Identifier.new('/foo/qux/', type: :legacy))
+      end
+
+      let!(:child_item) do
+        ctx.create_item('xyz', {}, Nanoc::Identifier.new('/foo/bar/asdf/', type: :legacy))
+      end
+
+      let!(:grandparent_item) do
+        ctx.create_item('opq', {}, Nanoc::Identifier.new('/', type: :legacy))
+      end
+
+      it 'returns parent' do
+        expect(subject).to eql(parent_item)
+      end
+    end
+
+    context 'full identifier' do
+      let(:identifier) { Nanoc::Identifier.new('/foo/bar.md', type: :full) }
+
+      let!(:parent_item) do
+        ctx.create_item('abc', {}, Nanoc::Identifier.new('/foo.md', type: :full))
+      end
+
+      let!(:sibling_item) do
+        ctx.create_item('def', {}, Nanoc::Identifier.new('/foo/qux.md', type: :full))
+      end
+
+      let!(:child_item) do
+        ctx.create_item('xyz', {}, Nanoc::Identifier.new('/foo/bar/asdf.md', type: :full))
+      end
+
+      let!(:grandparent_item) do
+        ctx.create_item('opq', {}, Nanoc::Identifier.new('/index.md', type: :full))
+      end
+
+      it 'returns parent' do
+        expect(subject).to eql(parent_item)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/filtering_spec.rb b/spec/nanoc/helpers/filtering_spec.rb
new file mode 100644
index 0000000..4dc49c3
--- /dev/null
+++ b/spec/nanoc/helpers/filtering_spec.rb
@@ -0,0 +1,72 @@
+describe Nanoc::Helpers::Filtering, helper: true do
+  describe '#filter' do
+    before do
+      ctx.item = ctx.create_item('some content', { title: 'Hello!' }, '/about.md')
+      ctx.item_rep = ctx.create_rep(ctx.item, '/about.html')
+    end
+
+    let(:content) do
+      "A<% filter :erb do %><%%= 'X' %><% end %>B"
+    end
+
+    subject { ::ERB.new(content).result(helper.get_binding) }
+
+    context 'basic case' do
+      it { is_expected.to eql('AXB') }
+
+      it 'notifies' do
+        ns = Set.new
+        Nanoc::Int::NotificationCenter.on(:filtering_started) { ns << :filtering_started }
+        Nanoc::Int::NotificationCenter.on(:filtering_ended)   { ns << :filtering_ended   }
+
+        subject
+
+        expect(ns).to include(:filtering_started)
+        expect(ns).to include(:filtering_ended)
+      end
+    end
+
+    context 'with assigns' do
+      let(:content) do
+        'A<% filter :erb do %><%%= @item[:title] %><% end %>B'
+      end
+
+      it { is_expected.to eql('AHello!B') }
+    end
+
+    context 'unknonwn filter name' do
+      let(:content) do
+        'A<% filter :donkey do %>X<% end %>B'
+      end
+
+      it 'errors' do
+        expect { subject }.to raise_error(Nanoc::Int::Errors::UnknownFilter)
+      end
+    end
+
+    context 'with locals' do
+      let(:content) do
+        "A<% filter :erb, locals: { sheep: 'baah' } do %><%%= @sheep %><% end %>B"
+      end
+
+      it { is_expected.to eql('AbaahB') }
+    end
+
+    context 'with Haml' do
+      let(:content) do
+        "%p Foo.\n" \
+        "- filter(:erb) do\n" \
+        "  <%= 'abc' + 'xyz' %>\n" \
+        "%p Bar.\n"
+      end
+
+      before do
+        require 'haml'
+      end
+
+      subject { ::Haml::Engine.new(content).render(helper.get_binding) }
+
+      it { is_expected.to match(%r{^<p>Foo.</p>\s*abcxyz\s*<p>Bar.</p>$}) }
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/html_escape_spec.rb b/spec/nanoc/helpers/html_escape_spec.rb
new file mode 100644
index 0000000..7599f4d
--- /dev/null
+++ b/spec/nanoc/helpers/html_escape_spec.rb
@@ -0,0 +1,35 @@
+describe Nanoc::Helpers::HTMLEscape, helper: true do
+  describe '#html_escape' do
+    subject { helper.html_escape(string) }
+
+    context 'given strings to escape' do
+      let(:string) { '< > & "' }
+      it { is_expected.to eql('< > & "') }
+    end
+
+    context 'given a block' do
+      let!(:_erbout) { 'moo' }
+
+      it 'adds escaped content to _erbout' do
+        helper.html_escape { _erbout << '<h1>Stuff!</h1>' }
+        expect(_erbout).to eql('moo<h1>Stuff!</h1>')
+      end
+    end
+
+    context 'given no argument nor block' do
+      subject { helper.html_escape }
+
+      it 'raises' do
+        expect { subject }.to raise_error(RuntimeError)
+      end
+    end
+
+    context 'given argument that is not a string' do
+      let(:string) { 1 }
+
+      it 'raises an ArgumentError' do
+        expect { subject }.to raise_error(ArgumentError)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/link_to_spec.rb b/spec/nanoc/helpers/link_to_spec.rb
new file mode 100644
index 0000000..3328dd7
--- /dev/null
+++ b/spec/nanoc/helpers/link_to_spec.rb
@@ -0,0 +1,275 @@
+describe Nanoc::Helpers::LinkTo, helper: true do
+  describe '#link_to' do
+    subject { helper.link_to(text, target, attributes) }
+
+    let(:text) { 'Text' }
+    let(:target) { raise 'override me' }
+    let(:attributes) { {} }
+
+    context 'with string path' do
+      let(:target) { '/foo/' }
+      it { is_expected.to eql('<a href="/foo/">Text</a>') }
+
+      context 'with attributes' do
+        let(:attributes) { { title: 'Donkey' } }
+        it { is_expected.to eql('<a title="Donkey" href="/foo/">Text</a>') }
+      end
+
+      context 'special HTML characters in text' do
+        let(:text) { 'Foo & Bar' }
+        it { is_expected.to eql('<a href="/foo/">Foo & Bar</a>') }
+        # Not escaped!
+      end
+
+      context 'special HTML characters in URL' do
+        let(:target) { '/r&d/' }
+        it { is_expected.to eql('<a href="/r&d/">Text</a>') }
+      end
+
+      context 'special HTML characters in attribute' do
+        let(:attributes) { { title: 'Research & Development' } }
+        it { is_expected.to eql('<a title="Research & Development" href="/foo/">Text</a>') }
+      end
+    end
+
+    context 'with rep' do
+      let(:item) { ctx.create_item('content', {}, '/target/') }
+      let(:target) { ctx.create_rep(item, '/target.html') }
+
+      it { is_expected.to eql('<a href="/target.html">Text</a>') }
+    end
+
+    context 'with item' do
+      let(:target) { ctx.create_item('content', {}, '/target/') }
+
+      before do
+        ctx.create_rep(target, '/target.html')
+      end
+
+      it { is_expected.to eql('<a href="/target.html">Text</a>') }
+    end
+
+    context 'with nil' do
+      let(:target) { nil }
+
+      it 'raises' do
+        expect { subject }.to raise_error(ArgumentError)
+      end
+    end
+
+    context 'with something else' do
+      let(:target) { :donkey }
+
+      it 'raises' do
+        expect { subject }.to raise_error(ArgumentError)
+      end
+    end
+
+    context 'with nil path' do
+      let(:item) { ctx.create_item('content', {}, '/target/') }
+      let(:target) { ctx.create_rep(item, nil) }
+
+      it 'raises' do
+        expect { subject }.to raise_error(RuntimeError)
+      end
+    end
+  end
+
+  describe '#link_to_unless_current' do
+    subject { helper.link_to_unless_current(text, target, attributes) }
+
+    let(:text) { 'Text' }
+    let(:target) { raise 'override me' }
+    let(:attributes) { {} }
+
+    context 'with string path' do
+      let(:target) { '/target.html' }
+
+      context 'current' do
+        before do
+          ctx.item = ctx.create_item('content', {}, '/target.md')
+          ctx.item_rep = ctx.create_rep(ctx.item, '/target.html')
+        end
+
+        it { is_expected.to eql('<span class="active">Text</span>') }
+      end
+
+      context 'no item rep present' do
+        it { is_expected.to eql('<a href="/target.html">Text</a>') }
+      end
+
+      context 'item rep present, but not current' do
+        before do
+          ctx.item = ctx.create_item('content', {}, '/other.md')
+          ctx.item_rep = ctx.create_rep(ctx.item, '/other.html')
+        end
+
+        it { is_expected.to eql('<a href="/target.html">Text</a>') }
+      end
+    end
+
+    context 'with rep' do
+      before do
+        ctx.item = ctx.create_item('content', {}, '/target.md')
+        ctx.item_rep = ctx.create_rep(ctx.item, '/target.html')
+      end
+
+      let(:some_item) { ctx.create_item('content', {}, '/other.md') }
+      let(:some_item_rep) { ctx.create_rep(some_item, '/other.html') }
+
+      context 'current' do
+        let(:target) { ctx.item_rep }
+        it { is_expected.to eql('<span class="active">Text</span>') }
+      end
+
+      context 'no item rep present' do
+        let(:target) { some_item_rep }
+
+        before do
+          ctx.item = nil
+          ctx.item_rep = nil
+        end
+
+        it { is_expected.to eql('<a href="/other.html">Text</a>') }
+      end
+
+      context 'item rep present, but not current' do
+        let(:target) { some_item_rep }
+        it { is_expected.to eql('<a href="/other.html">Text</a>') }
+      end
+    end
+
+    context 'with item' do
+      before do
+        ctx.item = ctx.create_item('content', {}, '/target.md')
+        ctx.item_rep = ctx.create_rep(ctx.item, '/target.html')
+      end
+
+      let!(:some_item) { ctx.create_item('content', {}, '/other.md') }
+      let!(:some_item_rep) { ctx.create_rep(some_item, '/other.html') }
+
+      context 'current' do
+        let(:target) { ctx.item }
+        it { is_expected.to eql('<span class="active">Text</span>') }
+      end
+
+      context 'no item rep present' do
+        let(:target) { some_item }
+
+        before do
+          ctx.item = nil
+          ctx.item_rep = nil
+        end
+
+        it { is_expected.to eql('<a href="/other.html">Text</a>') }
+      end
+
+      context 'item rep present, but not current' do
+        let(:target) { some_item }
+        it { is_expected.to eql('<a href="/other.html">Text</a>') }
+      end
+    end
+  end
+
+  describe '#relative_path_to' do
+    subject { helper.relative_path_to(target) }
+
+    before do
+      ctx.item = ctx.create_item('content', {}, '/foo/self.md')
+      ctx.item_rep = ctx.create_rep(ctx.item, self_path)
+    end
+
+    context 'current item rep has non-nil path' do
+      let(:self_path) { '/foo/self.html' }
+
+      context 'to string path' do
+        context 'to relative path' do
+          let(:target) { 'bar/target.html' }
+
+          it 'errors' do
+            # TODO: Might make sense to allow this case (and return the path itself)
+            expect { subject }.to raise_error(ArgumentError)
+          end
+        end
+
+        context 'to path without trailing slash' do
+          let(:target) { '/bar/target.html' }
+          it { is_expected.to eql('../bar/target.html') }
+        end
+
+        context 'to path with trailing slash' do
+          let(:target) { '/bar/target/' }
+          it { is_expected.to eql('../bar/target/') }
+        end
+
+        context 'to Windows/UNC path (forward slashes)' do
+          let(:target) { '//foo' }
+          it { is_expected.to eql('//foo') }
+        end
+
+        context 'to Windows/UNC path (backslashes)' do
+          let(:target) { '\\\\foo' }
+          it { is_expected.to eql('\\\\foo') }
+        end
+      end
+
+      context 'to rep' do
+        let(:target) { ctx.create_rep(ctx.item, '/bar/target.html') }
+        it { is_expected.to eql('../bar/target.html') }
+
+        context 'to self' do
+          let(:target) { ctx.item_rep }
+
+          context 'self is a filename' do
+            it { is_expected.to eql('self.html') }
+          end
+
+          context 'self is a directory' do
+            let(:self_path) { '/foo/self/' }
+            it { is_expected.to eql('./') }
+          end
+        end
+      end
+
+      context 'to item' do
+        let(:target) { ctx.create_item('content', {}, '/bar/target.md') }
+
+        before do
+          ctx.create_rep(target, '/bar/target.html')
+        end
+
+        it { is_expected.to eql('../bar/target.html') }
+
+        context 'to self' do
+          let(:target) { ctx.item }
+
+          context 'self is a filename' do
+            it { is_expected.to eql('self.html') }
+          end
+
+          context 'self is a directory' do
+            let(:self_path) { '/foo/self/' }
+            it { is_expected.to eql('./') }
+          end
+        end
+      end
+
+      context 'to nil path' do
+        let(:target) { ctx.create_rep(ctx.item, nil) }
+
+        it 'raises' do
+          expect { subject }.to raise_error(RuntimeError)
+        end
+      end
+    end
+
+    context 'current item rep has nil path' do
+      let(:self_path) { nil }
+      let(:target) { '/bar/target.html' }
+
+      it 'errors' do
+        expect { subject }.to raise_error(RuntimeError)
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/rendering_spec.rb b/spec/nanoc/helpers/rendering_spec.rb
new file mode 100644
index 0000000..5d4a939
--- /dev/null
+++ b/spec/nanoc/helpers/rendering_spec.rb
@@ -0,0 +1,141 @@
+describe Nanoc::Helpers::Rendering, helper: true do
+  describe '#render' do
+    subject { helper.instance_eval { render('/partial.erb') } }
+
+    let(:rule_memory_for_layout) do
+      [Nanoc::Int::ProcessingActions::Filter.new(:erb, {})]
+    end
+
+    let(:layout_view) do
+      ctx.create_layout(layout_content, {}, layout_identifier)
+    end
+
+    let(:layout) do
+      layout_view.unwrap
+    end
+
+    before do
+      ctx.update_rule_memory(layout, rule_memory_for_layout)
+    end
+
+    context 'legacy identifier' do
+      let(:layout_identifier) { Nanoc::Identifier.new('/partial/', type: :legacy) }
+
+      context 'cleaned identifier' do
+        subject { helper.instance_eval { render('/partial/') } }
+
+        context 'layout without instructions' do
+          let(:layout_content) { 'blah' }
+
+          it { is_expected.to eql('blah') }
+
+          it 'tracks proper dependencies' do
+            expect(ctx.dependency_tracker).to receive(:enter)
+              .with(layout, raw_content: true, attributes: false, compiled_content: false, path: false)
+            subject
+          end
+        end
+
+        context 'layout with instructions' do
+          let(:layout_content) { 'blah <%= @layout.identifier %>' }
+          it { is_expected.to eql('blah /partial/') }
+        end
+      end
+
+      context 'non-cleaned identifier' do
+        subject { helper.instance_eval { render('/partial') } }
+
+        context 'layout without instructions' do
+          let(:layout_content) { 'blah' }
+          it { is_expected.to eql('blah') }
+        end
+
+        context 'layout with instructions' do
+          let(:layout_content) { 'blah <%= @layout.identifier %>' }
+          it { is_expected.to eql('blah /partial/') }
+        end
+      end
+    end
+
+    context 'full-style identifier' do
+      let(:layout_identifier) { Nanoc::Identifier.new('/partial.erb') }
+
+      context 'layout without instructions' do
+        let(:layout_content) { 'blah' }
+        it { is_expected.to eql('blah') }
+      end
+
+      context 'layout with instructions' do
+        let(:layout_content) { 'blah <%= @layout.identifier %>' }
+        it { is_expected.to eql('blah /partial.erb') }
+      end
+
+      context 'printing wrapped layout class' do
+        let(:layout_content) { 'blah <%= @layout.class %>' }
+        it { is_expected.to eql('blah Nanoc::LayoutView') }
+      end
+
+      context 'printing unwrapped layout class' do
+        let(:layout_content) { 'blah <%= @layout.unwrap.class %>' }
+        it { is_expected.to eql('blah Nanoc::Int::Layout') }
+      end
+
+      context 'unknown layout' do
+        subject { helper.instance_eval { render('/unknown.erb') } }
+
+        let(:layout_content) { 'blah' }
+
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::UnknownLayout)
+        end
+      end
+
+      context 'layout with unknown filter' do
+        let(:rule_memory_for_layout) do
+          [Nanoc::Int::ProcessingActions::Filter.new(:donkey, {})]
+        end
+
+        let(:layout_content) { 'blah' }
+
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::UnknownFilter)
+        end
+      end
+
+      context 'layout without filter' do
+        let(:rule_memory_for_layout) do
+          [Nanoc::Int::ProcessingActions::Filter.new(nil, {})]
+        end
+
+        let(:layout_content) { 'blah' }
+
+        it 'raises' do
+          expect { subject }.to raise_error(Nanoc::Int::Errors::CannotDetermineFilter)
+        end
+      end
+
+      context 'with block' do
+        subject do
+          helper.instance_eval do
+            render('/partial.erb') { _erbout << 'extra content' }
+          end
+        end
+
+        before do
+          ctx.erbout << '[erbout-before]'
+        end
+
+        let(:layout_content) { '[partial-before]<%= yield %>[partial-after]' }
+
+        it 'returns an empty string' do
+          expect(subject).to eql('')
+        end
+
+        it 'modifies erbout' do
+          subject
+          expect(ctx.erbout).to eql('[erbout-before][partial-before]extra content[partial-after]')
+        end
+      end
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/tagging_spec.rb b/spec/nanoc/helpers/tagging_spec.rb
new file mode 100644
index 0000000..b31ec11
--- /dev/null
+++ b/spec/nanoc/helpers/tagging_spec.rb
@@ -0,0 +1,104 @@
+describe Nanoc::Helpers::Tagging, helper: true do
+  describe '#tags_for' do
+    subject { helper.tags_for(item, params) }
+
+    let(:item) { ctx.items['/me.*'] }
+    let(:params) { {} }
+    let(:item_attributes) { {} }
+
+    before do
+      ctx.create_item('content', item_attributes, '/me.md')
+    end
+
+    context 'no tags' do
+      let(:item_attributes) { {} }
+      it { is_expected.to eql('(none)') }
+    end
+
+    context 'nil tag list' do
+      let(:item_attributes) { { tags: nil } }
+      it { is_expected.to eql('(none)') }
+    end
+
+    context 'empty tag list' do
+      let(:item_attributes) { { tags: [] } }
+      it { is_expected.to eql('(none)') }
+    end
+
+    context 'no tags, and custom none text' do
+      let(:item_attributes) { {} }
+      let(:params) { { none_text: 'no tags for you, fool' } }
+      it { is_expected.to eql('no tags for you, fool') }
+    end
+
+    context 'one tag' do
+      let(:item_attributes) { { tags: %w(donkey) } }
+
+      context 'implicit base_url' do
+        it { is_expected.to eql('donkey') }
+      end
+
+      context 'explicit nil base_url' do
+        let(:params) { { base_url: nil } }
+        it { is_expected.to eql('donkey') }
+      end
+
+      context 'explicit other base_url' do
+        let(:params) { { base_url: 'http://nanoc.ws/tag/' } }
+        it { is_expected.to eql('<a href="http://nanoc.ws/tag/donkey" rel="tag">donkey</a>') }
+      end
+    end
+
+    context 'two tags' do
+      let(:item_attributes) { { tags: %w(donkey giraffe) } }
+      it { is_expected.to eql('donkey, giraffe') }
+    end
+
+    context 'three tags' do
+      let(:item_attributes) { { tags: %w(donkey giraffe zebra) } }
+      it { is_expected.to eql('donkey, giraffe, zebra') }
+
+      context 'custom separator' do
+        let(:item_attributes) { { tags: %w(donkey giraffe zebra) } }
+        let(:params) { { separator: ' / ' } }
+        it { is_expected.to eql('donkey / giraffe / zebra') }
+      end
+    end
+  end
+
+  describe '#items_with_tag' do
+    subject { helper.items_with_tag(tag) }
+
+    before do
+      ctx.create_item('item 1', { tags: [:foo] }, '/item1.md')
+      ctx.create_item('item 2', { tags: [:bar] }, '/item2.md')
+      ctx.create_item('item 3', { tags: [:foo, :bar] }, '/item3.md')
+      ctx.create_item('item 4', { tags: nil }, '/item4.md')
+      ctx.create_item('item 5', {}, '/item5.md')
+    end
+
+    context 'tag that exists' do
+      let(:tag) { :foo }
+      it { is_expected.to contain_exactly(ctx.items['/item1.md'], ctx.items['/item3.md']) }
+    end
+
+    context 'tag that does not exists' do
+      let(:tag) { :other }
+      it { is_expected.to be_empty }
+    end
+  end
+
+  describe '#link_for_tag' do
+    subject { helper.link_for_tag(tag, base_url) }
+
+    let(:tag) { 'foo' }
+    let(:base_url) { 'http://nanoc.ws/tag/' }
+
+    it { is_expected.to eql('<a href="http://nanoc.ws/tag/foo" rel="tag">foo</a>') }
+
+    context 'tag with special HTML characters' do
+      let(:tag) { 'R&D' }
+      it { is_expected.to eql('<a href="http://nanoc.ws/tag/R&D" rel="tag">R&D</a>') }
+    end
+  end
+end
diff --git a/spec/nanoc/helpers/text_spec.rb b/spec/nanoc/helpers/text_spec.rb
new file mode 100644
index 0000000..c77a585
--- /dev/null
+++ b/spec/nanoc/helpers/text_spec.rb
@@ -0,0 +1,58 @@
+describe Nanoc::Helpers::Text, helper: true do
+  describe '#excerptize' do
+    subject { helper.excerptize(string, params) }
+
+    let(:string) { 'Foo bar baz quux meow woof' }
+    let(:params) { {} }
+
+    context 'no params' do
+      it 'takes 25 characters' do
+        expect(subject).to eql('Foo bar baz quux meow ...')
+      end
+    end
+
+    context 'perfect fit' do
+      let(:params) { { length: 26 } }
+
+      it 'does not truncate' do
+        expect(subject).to eql('Foo bar baz quux meow woof')
+      end
+    end
+
+    context 'long length' do
+      let(:params) { { length: 27 } }
+
+      it 'does not truncate' do
+        expect(subject).to eql('Foo bar baz quux meow woof')
+      end
+    end
+
+    context 'short length' do
+      let(:params) { { length: 3 } }
+
+      it 'truncates' do
+        expect(subject).to eql('...')
+      end
+    end
+
+    context 'length shorter than omission' do
+      let(:params) { { length: 2 } }
+
+      it 'truncates, disregarding length' do
+        expect(subject).to eql('...')
+      end
+    end
+
+    context 'custom omission' do
+      let(:params) { { omission: '[continued]' } }
+
+      it 'uses custom omission string' do
+        expect(subject).to eql('Foo bar baz qu[continued]')
+      end
+    end
+  end
+
+  describe '#strip_html' do
+    # TODO: test this… or get rid of it (it’s bad!)
+  end
+end
diff --git a/spec/nanoc/integration/outdatedness_integration_spec.rb b/spec/nanoc/integration/outdatedness_integration_spec.rb
new file mode 100644
index 0000000..df1aa50
--- /dev/null
+++ b/spec/nanoc/integration/outdatedness_integration_spec.rb
@@ -0,0 +1,208 @@
+describe 'Outdatedness integration', site: true, stdio: true do
+  context 'only attribute dependency' do
+    before do
+      File.write('content/foo.md', "---\ntitle: hello\n---\n\nfoo")
+      File.write('content/bar.md', '<%= @items["/foo.*"][:title] %>')
+
+      File.write('Rules', <<EOS)
+compile '/foo.*' do
+  write '/foo.html'
+end
+
+compile '/bar.*' do
+  filter :erb
+  write '/bar.html'
+end
+EOS
+    end
+
+    before { Nanoc::CLI.run(%w(compile)) }
+
+    it 'shows default rep outdatedness' do
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+    end
+
+    it 'shows file as outdated after modification' do
+      File.write('content/bar.md', 'JUST BAR!')
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as not outdated after content modification' do
+      File.write('content/foo.md', "---\ntitle: hello\n---\n\nfoooOoooOOoooOooo")
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as outdated after title modification' do
+      File.write('content/foo.md', "---\ntitle: bye\n---\n\nfoo")
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+  end
+
+  context 'only raw content dependency' do
+    before do
+      File.write('content/foo.md', "---\ntitle: hello\n---\n\nfoo")
+      File.write('content/bar.md', '<%= @items["/foo.*"].raw_content %>')
+
+      File.write('Rules', <<EOS)
+compile '/foo.*' do
+  write '/foo.html'
+end
+
+compile '/bar.*' do
+  filter :erb
+  write '/bar.html'
+end
+EOS
+    end
+
+    before { Nanoc::CLI.run(%w(compile)) }
+
+    it 'shows default rep outdatedness' do
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+    end
+
+    it 'shows file as outdated after modification' do
+      File.write('content/bar.md', 'JUST BAR!')
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as outdated after content modification' do
+      File.write('content/foo.md', "---\ntitle: hello\n---\n\nfoooOoooOOoooOooo")
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as not outdated after title modification' do
+      File.write('content/foo.md', "---\ntitle: bye\n---\n\nfoo")
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+    end
+  end
+
+  context 'attribute and raw content dependency' do
+    before do
+      File.write('content/foo.md', "---\ntitle: hello\n---\n\nfoo")
+      File.write('content/bar.md', '<%= @items["/foo.*"].raw_content %> / <%= @items["/foo.*"][:title] %>')
+
+      File.write('Rules', <<EOS)
+compile '/foo.*' do
+  write '/foo.html'
+end
+
+compile '/bar.*' do
+  filter :erb
+  write '/bar.html'
+end
+EOS
+    end
+
+    before { Nanoc::CLI.run(%w(compile)) }
+
+    it 'shows default rep outdatedness' do
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+    end
+
+    it 'shows file as outdated after modification' do
+      File.write('content/bar.md', 'JUST BAR!')
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as outdated after content modification' do
+      File.write('content/foo.md', "---\ntitle: hello\n---\n\nfoooOoooOOoooOooo")
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as outdated after title modification' do
+      File.write('content/foo.md', "---\ntitle: bye\n---\n\nfoo")
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+    end
+
+    it 'shows file and dependencies as not outdated after rule modification' do
+      File.write('Rules', <<EOS)
+compile '/foo.*' do
+  filter :erb
+  write '/foo.html'
+end
+
+compile '/bar.*' do
+  filter :erb
+  write '/bar.html'
+end
+EOS
+
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+      )
+      expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+        output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+      )
+    end
+  end
+end
diff --git a/spec/nanoc/regressions/gh_1015_spec.rb b/spec/nanoc/regressions/gh_1015_spec.rb
new file mode 100644
index 0000000..af647da
--- /dev/null
+++ b/spec/nanoc/regressions/gh_1015_spec.rb
@@ -0,0 +1,17 @@
+describe 'GH-1015', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'I am foo!')
+
+    File.write('Rules', <<EOS)
+  compile '/foo.*' do
+    filter :erb, stuff: self
+    write 'foo.html'
+  end
+EOS
+  end
+
+  it 'errors' do
+    expect { Nanoc::CLI.run(%w(compile --verbose)) }.to raise_exception(Nanoc::Int::ItemRepRouter::RouteWithoutSlashError)
+    expect(File.file?('outputfoo.html')).not_to be
+  end
+end
diff --git a/spec/nanoc/regressions/gh_1031_spec.rb b/spec/nanoc/regressions/gh_1031_spec.rb
new file mode 100644
index 0000000..223fc41
--- /dev/null
+++ b/spec/nanoc/regressions/gh_1031_spec.rb
@@ -0,0 +1,54 @@
+describe 'GH-1031', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', '[<%= @items["/bar.*"].compiled_content %>]')
+    File.write('content/bar.md', 'I am bar!')
+
+    File.write('Rules', <<EOS)
+  compile '/bar.*' do
+    write '/bar.txt'
+  end
+
+  compile '/foo.*', rep: :default do
+    write '/foo.txt'
+  end
+
+  compile '/foo.*', rep: :depz do
+    filter :erb
+    write '/foo_deps.txt'
+  end
+EOS
+  end
+
+  it 'recompiles all reps of a changed item' do
+    Nanoc::CLI.run(%w(compile))
+    expect(File.file?('output/bar.txt')).to be
+    expect(File.file?('output/foo.txt')).to be
+    expect(File.file?('output/foo_deps.txt')).to be
+
+    File.write('Rules', <<EOS)
+  compile '/bar.*' do
+    write '/bar.txt'
+  end
+
+  compile '/foo.*', rep: :default do
+    write '/foo-new.txt'
+  end
+
+  compile '/foo.*', rep: :depz do
+    filter :erb
+    write '/foo_deps.txt'
+  end
+EOS
+
+    Nanoc::CLI.run(%w(compile))
+    expect(File.file?('output/bar.txt')).to be
+    expect(File.file?('output/foo.txt')).to be
+    expect(File.file?('output/foo_deps.txt')).to be
+    expect(File.read('output/foo_deps.txt')).to eq('[I am bar!]')
+
+    File.write('content/bar.md', 'I am a newer bar!')
+
+    Nanoc::CLI.run(%w(compile))
+    expect(File.read('output/foo_deps.txt')).to eq('[I am a newer bar!]')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_1035_spec.rb b/spec/nanoc/regressions/gh_1035_spec.rb
new file mode 100644
index 0000000..4a13ea9
--- /dev/null
+++ b/spec/nanoc/regressions/gh_1035_spec.rb
@@ -0,0 +1,33 @@
+describe 'GH-1035', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', '[<%= @items["/bar.*"].compiled_content(snapshot: :raw) %>]')
+    File.write('content/bar.md', 'I am bar!')
+
+    File.write('lib/stuff.rb', <<EOS)
+Class.new(Nanoc::Filter) do
+  identifier :gh_1031_text2bin
+  type :text => :binary
+
+  def run(content, params = {})
+    File.write(output_filename, content)
+  end
+end
+EOS
+
+    File.write('Rules', <<EOS)
+  compile '/bar.*' do
+    filter :gh_1031_text2bin
+  end
+
+  compile '/foo.*' do
+    filter :erb
+    write '/foo.txt'
+  end
+EOS
+  end
+
+  it 'can access textual content of now-binary item' do
+    Nanoc::CLI.run(%w(compile))
+    expect(File.read('output/foo.txt')).to eql('[I am bar!]')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_1040_spec.rb b/spec/nanoc/regressions/gh_1040_spec.rb
new file mode 100644
index 0000000..fd08aee
--- /dev/null
+++ b/spec/nanoc/regressions/gh_1040_spec.rb
@@ -0,0 +1,22 @@
+describe 'GH-1040', site: true, stdio: true do
+  before do
+    File.write('content/foo.txt', 'bar=<%= @items["/bar.*"].compiled_content %>')
+    File.write('content/bar.txt', 'foo=<%= @items["/foo.*"].compiled_content %>')
+
+    File.write('layouts/default.erb', '*<%= yield %>*')
+
+    File.write('Rules', <<EOS)
+  compile '/*' do
+    filter :erb
+    layout '/default.*'
+    write item.identifier
+  end
+
+  layout '/*.erb', :erb
+EOS
+  end
+
+  it 'errors' do
+    expect { Nanoc::CLI.run(%w(compile)) }.to raise_error(Nanoc::Int::Errors::RecursiveCompilation)
+  end
+end
diff --git a/spec/nanoc/regressions/gh_761_spec.rb b/spec/nanoc/regressions/gh_761_spec.rb
new file mode 100644
index 0000000..afbacff
--- /dev/null
+++ b/spec/nanoc/regressions/gh_761_spec.rb
@@ -0,0 +1,23 @@
+describe 'GH-761', site: true do
+  before do
+    File.write('content/donkey.md', 'Compiled content donkey!')
+
+    File.write('layouts/foo.erb', '[<%= @item.compiled_content %>]')
+
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    layout '/foo.*'
+    write '/donkey.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  it 'supports #compiled_content instead of yield' do
+    site = Nanoc::Int::SiteLoader.new.new_from_cwd
+    site.compile
+
+    expect(File.read('output/donkey.html')).to eql('[Compiled content donkey!]')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_767_spec.rb b/spec/nanoc/regressions/gh_767_spec.rb
new file mode 100644
index 0000000..fb73a2f
--- /dev/null
+++ b/spec/nanoc/regressions/gh_767_spec.rb
@@ -0,0 +1,19 @@
+describe 'GH-767', site: true do
+  before do
+    File.write('content/donkey.md', 'Compiled content donkey!')
+
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    filter :erb, stuff: item.path
+    write '/donkey.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  it 'does not expose #path on @item' do
+    site = Nanoc::Int::SiteLoader.new.new_from_cwd
+    expect { site.compile }.to raise_error(NoMethodError, /undefined method .*path.* for .*Nanoc::ItemWithoutRepsView/)
+  end
+end
diff --git a/spec/nanoc/regressions/gh_769_spec.rb b/spec/nanoc/regressions/gh_769_spec.rb
new file mode 100644
index 0000000..d3c3aad
--- /dev/null
+++ b/spec/nanoc/regressions/gh_769_spec.rb
@@ -0,0 +1,30 @@
+describe 'GH-769', site: true do
+  before do
+    File.write('content/index.md', 'Index!')
+    File.write('content/donkey.md', 'Donkey! [<%= @item.parent.identifier %>]')
+
+    File.open('nanoc.yaml', 'w') do |io|
+      io << 'string_pattern_type: legacy' << "\n"
+      io << 'data_sources:' << "\n"
+      io << '  -' << "\n"
+      io << '    type: filesystem' << "\n"
+      io << '    identifier_type: legacy' << "\n"
+    end
+
+    File.write('Rules', <<EOS)
+  compile '*' do
+    filter :erb
+    write item.identifier + 'index.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  it 'finds the parent if the parent is root' do
+    site = Nanoc::Int::SiteLoader.new.new_from_cwd
+    site.compile
+
+    expect(File.read('output/donkey/index.html')).to eql('Donkey! [/]')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_776_spec.rb b/spec/nanoc/regressions/gh_776_spec.rb
new file mode 100644
index 0000000..e847215
--- /dev/null
+++ b/spec/nanoc/regressions/gh_776_spec.rb
@@ -0,0 +1,43 @@
+describe 'GH-776', site: true do
+  before do
+    File.write('content/donkey.md', 'Donkey!')
+
+    File.write('Rules', <<EOS)
+  route '/donkey.*', snapshot: :secret do
+    '/donkey-secret.html'
+  end
+
+  compile '/donkey.*' do
+    filter :erb
+    snapshot :secret
+    write '/donkey.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  let(:site) { Nanoc::Int::SiteLoader.new.new_from_cwd }
+
+  before do
+    site.compile
+  end
+
+  context 'without pruning' do
+    it 'writes two files' do
+      expect(File.read('output/donkey.html')).to eql('Donkey!')
+      expect(File.read('output/donkey-secret.html')).to eql('Donkey!')
+    end
+  end
+
+  context 'with pruning' do
+    before do
+      Nanoc::Pruner.new(site.config, site.compiler.reps).run
+    end
+
+    it 'does not prune written snapshots' do
+      expect(File.read('output/donkey.html')).to eql('Donkey!')
+      expect(File.read('output/donkey-secret.html')).to eql('Donkey!')
+    end
+  end
+end
diff --git a/spec/nanoc/regressions/gh_787_spec.rb b/spec/nanoc/regressions/gh_787_spec.rb
new file mode 100644
index 0000000..395427f
--- /dev/null
+++ b/spec/nanoc/regressions/gh_787_spec.rb
@@ -0,0 +1,19 @@
+describe 'GH-787', site: true, stdio: true do
+  before do
+    File.write('Rules', <<EOS)
+  preprocess do
+    @items.create('foo', {}, '/pig.md')
+  end
+
+  compile '/**/*' do
+    write '/oink.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  it 'runs the preprocessor only once' do
+    expect { Nanoc::CLI.run(['compile']) }.not_to raise_error
+  end
+end
diff --git a/spec/nanoc/regressions/gh_795_spec.rb b/spec/nanoc/regressions/gh_795_spec.rb
new file mode 100644
index 0000000..0e03117
--- /dev/null
+++ b/spec/nanoc/regressions/gh_795_spec.rb
@@ -0,0 +1,19 @@
+describe 'GH-795', site: true, stdio: true do
+  before do
+    File.write('content/items.md', 'Frozen? <%= @items.unwrap.frozen? %>!')
+    File.write('content/items-view.md', 'Frozen? <%= @items.frozen? %>!')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    filter :erb
+    write item.identifier.without_ext + '.html'
+  end
+EOS
+  end
+
+  it 'freezes @items' do
+    Nanoc::CLI.run(['compile'])
+
+    expect(File.read('output/items.html')).to eql('Frozen? true!')
+    expect(File.read('output/items-view.html')).to eql('Frozen? true!')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_804_spec.rb b/spec/nanoc/regressions/gh_804_spec.rb
new file mode 100644
index 0000000..f39d93b
--- /dev/null
+++ b/spec/nanoc/regressions/gh_804_spec.rb
@@ -0,0 +1,26 @@
+describe 'GH-804', site: true, stdio: true do
+  before do
+    File.write('content/item.md', 'Stuff!')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    filter :erb if item[:dynamic]
+    write item.identifier.without_ext + '.html'
+  end
+EOS
+
+    File.write('Checks', <<EOS)
+check :donkey do
+  self.add_issue('Not enough donkeys')
+  self.add_issue('Too many cats', subject: '/catlady.md')
+end
+EOS
+  end
+
+  it 'does not crash' do
+    expect { Nanoc::CLI.run(%w(check donkey)) }.to(
+      raise_error(Nanoc::Int::Errors::GenericTrivial, 'One or more checks failed').and(
+        output(/Issues found!\n  \(global\):\n    \[ (\e\[31m)?ERROR(\e\[0m)? \] donkey - Not enough donkeys\n  \/catlady.md:\n    \[ (\e\[31m)?ERROR(\e\[0m)? \] donkey - Too many cats\n/).to_stdout,
+      ),
+    )
+  end
+end
diff --git a/spec/nanoc/regressions/gh_807_spec.rb b/spec/nanoc/regressions/gh_807_spec.rb
new file mode 100644
index 0000000..7231243
--- /dev/null
+++ b/spec/nanoc/regressions/gh_807_spec.rb
@@ -0,0 +1,17 @@
+describe 'GH-807', site: true, stdio: true do
+  before do
+    File.write('content/item.md', 'Stuff!')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    filter :erb if item[:dynamic]
+    write item.identifier.without_ext + '.html'
+  end
+EOS
+  end
+
+  it 'does not crash' do
+    Nanoc::CLI.run(['compile'])
+
+    expect(File.read('output/item.html')).to eql('Stuff!')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_809_spec.rb b/spec/nanoc/regressions/gh_809_spec.rb
new file mode 100644
index 0000000..fc67add
--- /dev/null
+++ b/spec/nanoc/regressions/gh_809_spec.rb
@@ -0,0 +1,17 @@
+describe 'GH-809', site: true, stdio: true do
+  before do
+    File.write('content/greeting.md', 'Hallöchen!')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    snapshot :something, path: '/greeting.tmp'
+  end
+EOS
+  end
+
+  specify 'stale check does not consider output/greeting.tmp as stale' do
+    Nanoc::CLI.run(['compile'])
+
+    regex = /Running check stale…   (\e\[32m)?ok(\e\[0m)?/
+    expect { Nanoc::CLI.run(%w(check stale)) }.to output(regex).to_stdout
+  end
+end
diff --git a/spec/nanoc/regressions/gh_813_spec.rb b/spec/nanoc/regressions/gh_813_spec.rb
new file mode 100644
index 0000000..8bf8625
--- /dev/null
+++ b/spec/nanoc/regressions/gh_813_spec.rb
@@ -0,0 +1,22 @@
+describe 'GH-813', site: true, stdio: true do
+  before do
+    File.write('nanoc.yaml', "enable_output_diff: true\n")
+    File.write('content/greeting.md', 'Hallöchen!')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    snapshot :donkey, path: '/donkey.html'
+    filter :kramdown
+  end
+EOS
+
+    Nanoc::CLI.run(['compile'])
+  end
+
+  specify 'Nanoc generates diff for proper path' do
+    File.write('content/greeting.md', 'Hellosies!')
+    Nanoc::CLI.run(['compile'])
+
+    diff = File.read('output.diff')
+    expect(diff).to start_with("--- output/donkey.html\n+++ output/donkey.html\n")
+  end
+end
diff --git a/spec/nanoc/regressions/gh_815_spec.rb b/spec/nanoc/regressions/gh_815_spec.rb
new file mode 100644
index 0000000..35eb11d
--- /dev/null
+++ b/spec/nanoc/regressions/gh_815_spec.rb
@@ -0,0 +1,18 @@
+describe 'GH-815', site: true, stdio: true do
+  before do
+    File.write('nanoc.yaml', "animal: \"donkey\"\n")
+    File.write('content/foo.md', '<%= @config.key?(:animal) %>')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    filter :erb
+    write item.identifier.without_ext + '.txt'
+  end
+EOS
+  end
+
+  it 'handles #key? properly' do
+    Nanoc::CLI.run(['compile'])
+
+    expect(File.read('output/foo.txt')).to eql('true')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_828_spec.rb b/spec/nanoc/regressions/gh_828_spec.rb
new file mode 100644
index 0000000..759787c
--- /dev/null
+++ b/spec/nanoc/regressions/gh_828_spec.rb
@@ -0,0 +1,23 @@
+describe 'GH-828', site: true, stdio: true do
+  before do
+    File.write('content/bad.md', "---\nbad: true\n---\n\nI am bad!")
+    File.write('content/good.md', "---\nbad: false\n---\n\nI am good!")
+    File.write('Rules', <<EOS)
+  preprocess do
+    @items.delete_if { |i| i[:bad] }
+  end
+
+  compile '/**/*' do
+    filter :erb
+    write item.identifier.without_ext + '.txt'
+  end
+EOS
+  end
+
+  it 'only writes good page' do
+    Nanoc::CLI.run(['compile'])
+
+    expect(File.file?('output/good.txt')).to be
+    expect(File.file?('output/bad.txt')).not_to be
+  end
+end
diff --git a/spec/nanoc/regressions/gh_833_spec.rb b/spec/nanoc/regressions/gh_833_spec.rb
new file mode 100644
index 0000000..4c7eb68
--- /dev/null
+++ b/spec/nanoc/regressions/gh_833_spec.rb
@@ -0,0 +1,14 @@
+describe 'GH-833', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'stuff')
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    write item.identifier.without_ext + '.txt'
+  end
+EOS
+  end
+
+  it 'runs show-data without crashing' do
+    Nanoc::CLI.run(['show-data'])
+  end
+end
diff --git a/spec/nanoc/regressions/gh_841_spec.rb b/spec/nanoc/regressions/gh_841_spec.rb
new file mode 100644
index 0000000..2302c7d
--- /dev/null
+++ b/spec/nanoc/regressions/gh_841_spec.rb
@@ -0,0 +1,15 @@
+describe 'GH-841', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'stuff')
+
+    File.write('Rules', <<EOS)
+  preprocess do
+    items.delete_if { |_| true }
+  end
+EOS
+  end
+
+  it 'preprocesses before running the check' do
+    Nanoc::CLI.run(%w(check stale))
+  end
+end
diff --git a/spec/nanoc/regressions/gh_867_spec.rb b/spec/nanoc/regressions/gh_867_spec.rb
new file mode 100644
index 0000000..9e9c7e3
--- /dev/null
+++ b/spec/nanoc/regressions/gh_867_spec.rb
@@ -0,0 +1,15 @@
+describe 'GH-867', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'stuff')
+
+    File.write('Rules', <<EOS)
+  preprocess do
+    items.delete_if { |_| true }
+  end
+EOS
+  end
+
+  it 'preprocesses before running show-data' do
+    expect { Nanoc::CLI.run(%w(show-data)) }.not_to output(/foo/).to_stdout
+  end
+end
diff --git a/spec/nanoc/regressions/gh_882_spec.rb b/spec/nanoc/regressions/gh_882_spec.rb
new file mode 100644
index 0000000..86baa5d
--- /dev/null
+++ b/spec/nanoc/regressions/gh_882_spec.rb
@@ -0,0 +1,29 @@
+describe 'GH-882', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'I am foo!')
+    File.write('content/bar.md', 'I am bar!')
+
+    File.write('Rules', <<EOS)
+  compile '/**/*' do
+    write item.identifier.without_ext + '.html'
+  end
+
+  postprocess do
+    modified_reps = items.flat_map(&:modified)
+    modified_reps.each do |rep|
+      puts "Modified: \#{rep.item.identifier} - \#{rep.name}"
+    end
+  end
+EOS
+  end
+
+  example do
+    Nanoc::CLI.run(%w(compile))
+
+    File.write('content/bar.md', 'I am bar! Modified!')
+    expect { Nanoc::CLI.run(%w(compile)) }.to output(%r{^Modified: /bar.md - default$}).to_stdout
+
+    File.write('content/bar.md', 'I am bar! Modified again!')
+    expect { Nanoc::CLI.run(%w(compile)) }.not_to output(%r{^Modified: /foo.md - default$}).to_stdout
+  end
+end
diff --git a/spec/nanoc/regressions/gh_885_spec.rb b/spec/nanoc/regressions/gh_885_spec.rb
new file mode 100644
index 0000000..095b888
--- /dev/null
+++ b/spec/nanoc/regressions/gh_885_spec.rb
@@ -0,0 +1,30 @@
+describe 'GH-885', site: true, stdio: true do
+  before do
+    File.write(
+      'content/index.html',
+      "<%= @items['/hello.*'].compiled_content %> - <%= Time.now.to_f %>",
+    )
+
+    File.write('Rules', <<EOS)
+  preprocess do
+    items.create('hi!', {}, '/hello.html')
+  end
+
+  compile '/**/*' do
+    filter :erb
+    write item.identifier.without_ext + '.html'
+  end
+EOS
+  end
+
+  example do
+    Nanoc::CLI.run(%w(compile))
+    before = File.read('output/index.html')
+
+    sleep(0.1)
+    Nanoc::CLI.run(%w(compile))
+    after = File.read('output/index.html')
+    expect(after).to eql(before)
+    expect(after).to match(/\Ahi! - \d+/)
+  end
+end
diff --git a/spec/nanoc/regressions/gh_891_spec.rb b/spec/nanoc/regressions/gh_891_spec.rb
new file mode 100644
index 0000000..476cfe6
--- /dev/null
+++ b/spec/nanoc/regressions/gh_891_spec.rb
@@ -0,0 +1,26 @@
+describe 'GH-891', site: true, stdio: true do
+  before do
+    File.write('layouts/foo.erb', 'giraffes? <%= yield %>')
+    File.write('Rules', <<EOS)
+  preprocess do
+    items.create('yes!', {}, '/hello.html')
+  end
+
+  compile '/**/*' do
+    layout '/foo.*'
+    write item.identifier.without_ext + '.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  example do
+    Nanoc::CLI.run(%w(compile))
+    expect(File.read('output/hello.html')).to include('giraffes?')
+
+    File.write('layouts/foo.erb', 'donkeys? <%= yield %>')
+    Nanoc::CLI.run(%w(compile))
+    expect(File.read('output/hello.html')).to include('donkeys?')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_913_spec.rb b/spec/nanoc/regressions/gh_913_spec.rb
new file mode 100644
index 0000000..7118ad9
--- /dev/null
+++ b/spec/nanoc/regressions/gh_913_spec.rb
@@ -0,0 +1,24 @@
+describe 'GH-913', site: true, stdio: true do
+  before do
+    File.write('content/hello.html', 'hi!')
+
+    File.write('Rules', <<EOS)
+  postprocess do
+    items.map(&:compiled_content)
+  end
+
+  compile '/**/*' do
+    write item.identifier.without_ext + '.html'
+  end
+
+  layout '/foo.*', :erb
+EOS
+  end
+
+  example do
+    2.times do
+      Nanoc::CLI.run(%w(compile))
+      expect(File.read('output/hello.html')).to eq('hi!')
+    end
+  end
+end
diff --git a/spec/nanoc/regressions/gh_928_spec.rb b/spec/nanoc/regressions/gh_928_spec.rb
new file mode 100644
index 0000000..f4b95c6
--- /dev/null
+++ b/spec/nanoc/regressions/gh_928_spec.rb
@@ -0,0 +1,5 @@
+describe 'GH-928', site: true, stdio: true do
+  example do
+    expect { Nanoc::CLI.run(%w(check --list)) }.to output(%r{^  css$}).to_stdout
+  end
+end
diff --git a/spec/nanoc/regressions/gh_937_spec.rb b/spec/nanoc/regressions/gh_937_spec.rb
new file mode 100644
index 0000000..ea2fadb
--- /dev/null
+++ b/spec/nanoc/regressions/gh_937_spec.rb
@@ -0,0 +1,25 @@
+describe 'GH-937', site: true, stdio: true do
+  before do
+    File.write('content/style.sass', ".test\n  color: red")
+
+    File.write(
+      'nanoc.yaml',
+      "sass_style: compact\nenvironments:\n  staging:\n    sass_style: expanded",
+    )
+
+    File.write('Rules', <<EOS)
+compile '/*.sass' do
+  filter :sass, style: @config[:sass_style].to_sym
+  write item.identifier.without_ext + '.css'
+end
+EOS
+  end
+
+  it 'does not use cache when switching environments' do
+    Nanoc::CLI.run(%w(compile))
+    expect(File.read('output/style.css')).to eq(".test { color: red; }\n")
+
+    Nanoc::CLI.run(%w(compile --env=staging))
+    expect(File.read('output/style.css')).to eq(".test {\n  color: red;\n}\n")
+  end
+end
diff --git a/spec/nanoc/regressions/gh_942_spec.rb b/spec/nanoc/regressions/gh_942_spec.rb
new file mode 100644
index 0000000..a0bb383
--- /dev/null
+++ b/spec/nanoc/regressions/gh_942_spec.rb
@@ -0,0 +1,21 @@
+describe 'GH-942', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'Foo!')
+    File.write('Rules', <<EOS)
+  compile '/foo.*' do
+    write '/parent/foo'
+  end
+EOS
+
+    File.open('nanoc.yaml', 'w') do |io|
+      io << 'prune:' << "\n"
+      io << '  auto_prune: true' << "\n"
+    end
+  end
+
+  example do
+    File.write('output/parent', 'Hahaaa! I am a file and not a directory!')
+    Nanoc::CLI.run(%w(compile))
+    expect(File.read('output/parent/foo')).to eq('Foo!')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_947_spec.rb b/spec/nanoc/regressions/gh_947_spec.rb
new file mode 100644
index 0000000..54c704d
--- /dev/null
+++ b/spec/nanoc/regressions/gh_947_spec.rb
@@ -0,0 +1,21 @@
+describe 'GH-947', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'Foo!')
+    File.write('Rules', <<EOS)
+  compile '/foo.*' do
+    write '/foo'
+  end
+EOS
+
+    File.open('nanoc.yaml', 'w') do |io|
+      io << 'prune:' << "\n"
+      io << '  auto_prune: true' << "\n"
+    end
+  end
+
+  example do
+    File.write('output/foo', 'I am an older foo!')
+    expect { Nanoc::CLI.run(%w(compile)) }.to output(%r{\s+update.*  output/foo$}).to_stdout
+    expect(File.read('output/foo')).to eq('Foo!')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_948_spec.rb b/spec/nanoc/regressions/gh_948_spec.rb
new file mode 100644
index 0000000..e7b9d84
--- /dev/null
+++ b/spec/nanoc/regressions/gh_948_spec.rb
@@ -0,0 +1,16 @@
+describe 'GH-948', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'Foo!')
+
+    File.open('nanoc.yaml', 'w') do |io|
+      io << 'prune:' << "\n"
+      io << '  auto_prune: true' << "\n"
+    end
+
+    FileUtils.rm_rf('output')
+  end
+
+  it 'does not crash when output dir is not present' do
+    Nanoc::CLI.run(%w(compile))
+  end
+end
diff --git a/spec/nanoc/regressions/gh_951_spec.rb b/spec/nanoc/regressions/gh_951_spec.rb
new file mode 100644
index 0000000..aa284fb
--- /dev/null
+++ b/spec/nanoc/regressions/gh_951_spec.rb
@@ -0,0 +1,19 @@
+describe 'GH-951', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'Foo!')
+
+    File.open('nanoc.yaml', 'w') do |io|
+      io << 'string_pattern_type: legacy' << "\n"
+    end
+
+    File.write('Rules', <<EOS)
+  passthrough '/foo.md'
+EOS
+  end
+
+  it 'copies foo.md' do
+    Nanoc::CLI.run(%w(compile))
+
+    expect(File.file?('output/foo.md')).to eq(true)
+  end
+end
diff --git a/spec/nanoc/regressions/gh_954_spec.rb b/spec/nanoc/regressions/gh_954_spec.rb
new file mode 100644
index 0000000..c44709e
--- /dev/null
+++ b/spec/nanoc/regressions/gh_954_spec.rb
@@ -0,0 +1,33 @@
+describe 'GH-954', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'foo <a href="/">root</a>')
+    File.write('content/bar.md', 'bar <a href="/">root</a>')
+    File.write('content/bar-copy.md', '<%= @items["/bar.*"].compiled_content(snapshot: :last) %>')
+
+    File.write('Rules', <<EOS)
+compile '/foo.*' do
+  filter :relativize_paths, type: :html unless rep.path.nil?
+  write item.identifier.without_ext + '.html'
+end
+
+compile '/bar.*' do
+  filter :relativize_paths, type: :html unless rep.path.nil?
+end
+
+compile '/bar-copy.*' do
+  filter :erb
+  write item.identifier.without_ext + '.html'
+end
+EOS
+  end
+
+  it 'properly filters foo.md' do
+    Nanoc::CLI.run(%w(compile))
+
+    # Path is relativized
+    expect(File.read('output/foo.html')).to eq('foo <a href="./">root</a>')
+
+    # Path is not relativized
+    expect(File.read('output/bar-copy.html')).to eq('bar <a href="/">root</a>')
+  end
+end
diff --git a/spec/nanoc/regressions/gh_970a_spec.rb b/spec/nanoc/regressions/gh_970a_spec.rb
new file mode 100644
index 0000000..b36176d
--- /dev/null
+++ b/spec/nanoc/regressions/gh_970a_spec.rb
@@ -0,0 +1,17 @@
+describe 'GH-970 (show-rules)', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'foo')
+
+    File.write('Rules', <<EOS)
+compile '/foo.*' do
+  write '/donkey.html'
+end
+EOS
+  end
+
+  it 'shows reps' do
+    expect { Nanoc::CLI.run(%w(show-rules --no-color)) }.to(
+      output(/^Item \/foo\.md:\n  Rep default: \/foo\.\*$/).to_stdout,
+    )
+  end
+end
diff --git a/spec/nanoc/regressions/gh_970b_spec.rb b/spec/nanoc/regressions/gh_970b_spec.rb
new file mode 100644
index 0000000..0b03932
--- /dev/null
+++ b/spec/nanoc/regressions/gh_970b_spec.rb
@@ -0,0 +1,50 @@
+describe 'GH-970 (show-data)', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'foo')
+    File.write('content/bar.md', '<%= @items["/foo.*"].compiled_content %>')
+
+    File.write('Rules', <<EOS)
+compile '/foo.*' do
+  write '/foo.html'
+end
+
+compile '/bar.*' do
+  filter :erb
+  write '/bar.html'
+end
+EOS
+  end
+
+  before { Nanoc::CLI.run(%w(compile)) }
+
+  it 'shows default rep outdatedness' do
+    expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+      output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+    )
+    expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+      output(/^item \/bar\.md, rep default:\n  is not outdated/).to_stdout,
+    )
+  end
+
+  it 'shows file as outdated after modification' do
+    File.write('content/bar.md', 'JUST BAR!')
+
+    expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+      output(/^item \/foo\.md, rep default:\n  is not outdated/).to_stdout,
+    )
+    expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+      output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+    )
+  end
+
+  it 'shows file and dependencies as outdated after modification' do
+    File.write('content/foo.md', 'FOO!')
+
+    expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+      output(/^item \/foo\.md, rep default:\n  is outdated: /).to_stdout,
+    )
+    expect { Nanoc::CLI.run(%w(show-data --no-color)) }.to(
+      output(/^item \/bar\.md, rep default:\n  is outdated: /).to_stdout,
+    )
+  end
+end
diff --git a/spec/nanoc/regressions/gh_974_spec.rb b/spec/nanoc/regressions/gh_974_spec.rb
new file mode 100644
index 0000000..0b5767c
--- /dev/null
+++ b/spec/nanoc/regressions/gh_974_spec.rb
@@ -0,0 +1,17 @@
+describe 'GH-974', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'foo')
+
+    File.write('Rules', <<EOS)
+compile '/foo.*' do
+  write item.identifier
+end
+EOS
+  end
+
+  it 'writes to path corresponding to identifier' do
+    Nanoc::CLI.run(%w(compile))
+
+    expect(File.file?('output/foo.md')).to eq(true)
+  end
+end
diff --git a/spec/nanoc/regressions/gh_981_spec.rb b/spec/nanoc/regressions/gh_981_spec.rb
new file mode 100644
index 0000000..6e1867a
--- /dev/null
+++ b/spec/nanoc/regressions/gh_981_spec.rb
@@ -0,0 +1,21 @@
+describe 'GH-981', site: true, stdio: true do
+  before do
+    File.write('content/foo.md', 'I am foo!')
+
+    File.write('Rules', <<EOS)
+  compile '/foo.*' do
+    filter :erb, stuff: self
+    write '/foo.html'
+  end
+EOS
+  end
+
+  it 'creates at first' do
+    expect { Nanoc::CLI.run(%w(compile --verbose)) }.to output(%r{create.*output/foo\.html$}).to_stdout
+  end
+
+  it 'skips the item on second try' do
+    Nanoc::CLI.run(%w(compile))
+    expect { Nanoc::CLI.run(%w(compile --verbose)) }.to output(%r{skip.*output/foo\.html$}).to_stdout
+  end
+end
diff --git a/spec/nanoc/rule_dsl/recording_executor_spec.rb b/spec/nanoc/rule_dsl/recording_executor_spec.rb
new file mode 100644
index 0000000..f6d2e97
--- /dev/null
+++ b/spec/nanoc/rule_dsl/recording_executor_spec.rb
@@ -0,0 +1,142 @@
+describe Nanoc::RuleDSL::RecordingExecutor do
+  let(:executor) { described_class.new(rule_memory) }
+
+  let(:rule_memory) { Nanoc::Int::RuleMemory.new(rep) }
+  let(:rep) { double(:rep) }
+
+  describe '#filter' do
+    it 'records filter call without arguments' do
+      executor.filter(:erb)
+
+      expect(rule_memory.size).to eql(1)
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Filter)
+      expect(rule_memory[0].filter_name).to eql(:erb)
+      expect(rule_memory[0].params).to eql({})
+    end
+
+    it 'records filter call with arguments' do
+      executor.filter(:erb, x: 123)
+
+      expect(rule_memory.size).to eql(1)
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Filter)
+      expect(rule_memory[0].filter_name).to eql(:erb)
+      expect(rule_memory[0].params).to eql(x: 123)
+    end
+  end
+
+  describe '#layout' do
+    it 'records layout call without arguments' do
+      executor.layout('/default.*')
+
+      expect(rule_memory.size).to eql(2)
+
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+      expect(rule_memory[0].snapshot_name).to eql(:pre)
+      expect(rule_memory[0].path).to be_nil
+
+      expect(rule_memory[1]).to be_a(Nanoc::Int::ProcessingActions::Layout)
+      expect(rule_memory[1].layout_identifier).to eql('/default.*')
+      expect(rule_memory[1].params).to eql({})
+    end
+
+    it 'records layout call with arguments' do
+      executor.layout('/default.*', donkey: 123)
+
+      expect(rule_memory.size).to eql(2)
+
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+      expect(rule_memory[0].snapshot_name).to eql(:pre)
+      expect(rule_memory[0].path).to be_nil
+
+      expect(rule_memory[1]).to be_a(Nanoc::Int::ProcessingActions::Layout)
+      expect(rule_memory[1].layout_identifier).to eql('/default.*')
+      expect(rule_memory[1].params).to eql(donkey: 123)
+    end
+
+    it 'fails when passed a symbol' do
+      expect { executor.layout(:default, donkey: 123) }.to raise_error(ArgumentError)
+    end
+  end
+
+  describe '#snapshot' do
+    context 'snapshot already exists' do
+      before do
+        executor.snapshot(:foo)
+      end
+
+      it 'raises when creating same snapshot' do
+        expect { executor.snapshot(:foo) }
+          .to raise_error(Nanoc::Int::Errors::CannotCreateMultipleSnapshotsWithSameName)
+      end
+    end
+
+    context 'no arguments' do
+      subject { executor.snapshot(:foo) }
+
+      it 'records' do
+        subject
+        expect(rule_memory.size).to eql(1)
+        expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+        expect(rule_memory[0].snapshot_name).to eql(:foo)
+        expect(rule_memory[0].path).to be_nil
+      end
+    end
+
+    context 'final argument' do
+      subject { executor.snapshot(:foo, path: path) }
+      let(:path) { nil }
+
+      context 'routing rule does not exist' do
+        context 'no explicit path given' do
+          it 'records' do
+            subject
+            expect(rule_memory.size).to eql(1)
+            expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+            expect(rule_memory[0].snapshot_name).to eql(:foo)
+            expect(rule_memory[0].path).to be_nil
+          end
+        end
+
+        context 'explicit path given as string' do
+          let(:path) { '/routed-foo.html' }
+
+          it 'records' do
+            subject
+            expect(rule_memory.size).to eql(1)
+            expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+            expect(rule_memory[0].snapshot_name).to eql(:foo)
+            expect(rule_memory[0].path).to eql('/routed-foo.html')
+          end
+        end
+
+        context 'explicit path given as identifier' do
+          let(:path) { Nanoc::Identifier.from('/routed-foo.html') }
+
+          it 'records' do
+            subject
+            expect(rule_memory.size).to eql(1)
+            expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+            expect(rule_memory[0].snapshot_name).to eql(:foo)
+            expect(rule_memory[0].path).to eql('/routed-foo.html')
+          end
+        end
+      end
+    end
+
+    it 'raises when given unknown arguments' do
+      expect { executor.snapshot(:foo, animal: 'giraffe') }
+        .to raise_error(ArgumentError)
+    end
+
+    it 'can create multiple snapshots with different names' do
+      executor.snapshot(:foo)
+      executor.snapshot(:bar)
+
+      expect(rule_memory.size).to eql(2)
+      expect(rule_memory[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+      expect(rule_memory[0].snapshot_name).to eql(:foo)
+      expect(rule_memory[1]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+      expect(rule_memory[1].snapshot_name).to eql(:bar)
+    end
+  end
+end
diff --git a/spec/nanoc/rule_dsl/rule_context_spec.rb b/spec/nanoc/rule_dsl/rule_context_spec.rb
new file mode 100644
index 0000000..79e1248
--- /dev/null
+++ b/spec/nanoc/rule_dsl/rule_context_spec.rb
@@ -0,0 +1,177 @@
+describe(Nanoc::RuleDSL::RuleContext) do
+  subject(:rule_context) do
+    described_class.new(rep: rep, site: site, executor: executor, view_context: view_context)
+  end
+
+  let(:item_identifier) { Nanoc::Identifier.new('/foo.md') }
+  let(:item) { Nanoc::Int::Item.new('content', {}, item_identifier) }
+  let(:config) { Nanoc::Int::Configuration.new }
+  let(:items) { Nanoc::Int::IdentifiableCollection.new(config) }
+  let(:layouts) { Nanoc::Int::IdentifiableCollection.new(config) }
+
+  let(:rep) { double(:rep, item: item) }
+  let(:site) { double(:site, items: items, layouts: layouts, config: config) }
+  let(:executor) { double(:executor) }
+  let(:reps) { double(:reps) }
+  let(:compilation_context) { double(:compilation_context) }
+  let(:view_context) { Nanoc::ViewContext.new(reps: reps, items: items, dependency_tracker: dependency_tracker, compilation_context: compilation_context) }
+  let(:dependency_tracker) { double(:dependency_tracker) }
+
+  describe '#initialize' do
+    it 'wraps objects in view classes' do
+      expect(subject.rep.class).to eql(Nanoc::ItemRepView)
+      expect(subject.item.class).to eql(Nanoc::ItemWithoutRepsView)
+      expect(subject.config.class).to eql(Nanoc::ConfigView)
+      expect(subject.layouts.class).to eql(Nanoc::LayoutCollectionView)
+      expect(subject.items.class).to eql(Nanoc::ItemCollectionWithoutRepsView)
+    end
+
+    it 'contains the right objects' do
+      expect(rule_context.rep.unwrap).to eql(rep)
+      expect(rule_context.item.unwrap).to eql(item)
+      expect(rule_context.config.unwrap).to eql(config)
+      expect(rule_context.layouts.unwrap).to eql(layouts)
+      expect(rule_context.items.unwrap).to eql(items)
+    end
+  end
+
+  describe '#item' do
+    subject { rule_context.item }
+
+    it 'is a view without reps access' do
+      expect(subject.class).to eql(Nanoc::ItemWithoutRepsView)
+    end
+
+    it 'contains the right item' do
+      expect(subject.unwrap).to eql(item)
+    end
+
+    context 'with legacy identifier and children/parent' do
+      let(:item_identifier) { Nanoc::Identifier.new('/foo/', type: :legacy) }
+
+      let(:parent_identifier) { Nanoc::Identifier.new('/', type: :legacy) }
+      let(:parent) { Nanoc::Int::Item.new('parent', {}, parent_identifier) }
+
+      let(:child_identifier) { Nanoc::Identifier.new('/foo/bar/', type: :legacy) }
+      let(:child) { Nanoc::Int::Item.new('child', {}, child_identifier) }
+
+      before do
+        items << item
+        items << parent
+        items << child
+      end
+
+      it 'has a parent' do
+        expect(subject.parent.unwrap).to eql(parent)
+      end
+
+      it 'wraps the parent in a view without reps access' do
+        expect(subject.parent.class).to eql(Nanoc::ItemWithoutRepsView)
+        expect(subject.parent).not_to respond_to(:compiled_content)
+        expect(subject.parent).not_to respond_to(:path)
+        expect(subject.parent).not_to respond_to(:reps)
+      end
+
+      it 'has children' do
+        expect(subject.children.map(&:unwrap)).to eql([child])
+      end
+
+      it 'wraps the children in a view without reps access' do
+        expect(subject.children.map(&:class)).to eql([Nanoc::ItemWithoutRepsView])
+        expect(subject.children[0]).not_to respond_to(:compiled_content)
+        expect(subject.children[0]).not_to respond_to(:path)
+        expect(subject.children[0]).not_to respond_to(:reps)
+      end
+    end
+  end
+
+  describe '#items' do
+    subject { rule_context.items }
+
+    let(:item_identifier) { Nanoc::Identifier.new('/foo/', type: :legacy) }
+
+    let(:parent_identifier) { Nanoc::Identifier.new('/', type: :legacy) }
+    let(:parent) { Nanoc::Int::Item.new('parent', {}, parent_identifier) }
+
+    let(:child_identifier) { Nanoc::Identifier.new('/foo/bar/', type: :legacy) }
+    let(:child) { Nanoc::Int::Item.new('child', {}, child_identifier) }
+
+    before do
+      items << item
+      items << parent
+      items << child
+    end
+
+    it 'is a view without reps access' do
+      expect(subject.class).to eql(Nanoc::ItemCollectionWithoutRepsView)
+    end
+
+    it 'contains all items' do
+      expect(subject.unwrap).to match_array([item, parent, child])
+    end
+
+    it 'provides no rep access' do
+      expect(subject['/']).not_to be_nil
+      expect(subject['/']).not_to respond_to(:compiled_content)
+      expect(subject['/']).not_to respond_to(:path)
+      expect(subject['/']).not_to respond_to(:reps)
+
+      expect(subject['/foo/']).not_to be_nil
+      expect(subject['/foo/']).not_to respond_to(:compiled_content)
+      expect(subject['/foo/']).not_to respond_to(:path)
+      expect(subject['/foo/']).not_to respond_to(:reps)
+
+      expect(subject['/foo/bar/']).not_to be_nil
+      expect(subject['/foo/bar/']).not_to respond_to(:compiled_content)
+      expect(subject['/foo/bar/']).not_to respond_to(:path)
+      expect(subject['/foo/bar/']).not_to respond_to(:reps)
+    end
+  end
+
+  describe '#filter' do
+    subject { rule_context.filter(filter_name, filter_args) }
+
+    let(:filter_name) { :donkey }
+    let(:filter_args) { { color: 'grey' } }
+
+    it 'makes a request to the executor' do
+      expect(executor).to receive(:filter).with(filter_name, filter_args)
+      subject
+    end
+  end
+
+  describe '#layout' do
+    subject { rule_context.layout(layout_identifier, extra_filter_args) }
+
+    let(:layout_identifier) { '/default.*' }
+    let(:extra_filter_args) { { color: 'grey' } }
+
+    it 'makes a request to the executor' do
+      expect(executor).to receive(:layout).with(layout_identifier, extra_filter_args)
+      subject
+    end
+  end
+
+  describe '#snapshot' do
+    subject { rule_context.snapshot(snapshot_name, path: path) }
+
+    let(:snapshot_name) { :for_snippet }
+    let(:path) { '/foo.html' }
+
+    it 'makes a request to the executor' do
+      expect(executor).to receive(:snapshot).with(:for_snippet, path: '/foo.html')
+      subject
+    end
+  end
+
+  describe '#write' do
+    subject { rule_context.write(path) }
+
+    let(:path) { '/foo.html' }
+
+    it 'makes a request to the executor' do
+      expect(executor).to receive(:snapshot).with(:last, path: '/foo.html')
+      subject
+    end
+  end
+end
diff --git a/spec/nanoc/rule_dsl/rule_memory_calculator_spec.rb b/spec/nanoc/rule_dsl/rule_memory_calculator_spec.rb
new file mode 100644
index 0000000..b506ecf
--- /dev/null
+++ b/spec/nanoc/rule_dsl/rule_memory_calculator_spec.rb
@@ -0,0 +1,233 @@
+describe(Nanoc::RuleDSL::RuleMemoryCalculator) do
+  subject(:rule_memory_calculator) do
+    described_class.new(site: site, rules_collection: rules_collection)
+  end
+
+  let(:rules_collection) { Nanoc::RuleDSL::RulesCollection.new }
+  let(:site) { double(:site) }
+
+  describe '#[]' do
+    subject { rule_memory_calculator[obj] }
+
+    context 'with item rep' do
+      let(:obj) { Nanoc::Int::ItemRep.new(item, :csv) }
+
+      let(:item) { Nanoc::Int::Item.new('content', {}, Nanoc::Identifier.from('/list.md')) }
+      let(:config) { Nanoc::Int::Configuration.new.with_defaults }
+      let(:items) { Nanoc::Int::IdentifiableCollection.new(config) }
+      let(:layouts) { Nanoc::Int::IdentifiableCollection.new(config) }
+      let(:site) { double(:site, items: items, layouts: layouts, config: config, compiler: compiler) }
+      let(:compiler) { double(:compiler, compilation_context: compilation_context) }
+      let(:compilation_context) { double(:compilation_context) }
+      let(:view_context) { double(:view_context) }
+
+      before do
+        expect(compilation_context).to receive(:create_view_context).and_return(view_context)
+      end
+
+      context 'no rules exist' do
+        it 'raises error' do
+          error = Nanoc::RuleDSL::RuleMemoryCalculator::NoRuleMemoryForItemRepException
+          expect { subject }.to raise_error(error)
+        end
+      end
+
+      context 'rules exist' do
+        before do
+          rules_proc = proc do
+            filter :erb, speed: :over_9000
+            layout '/default.*'
+            filter :typohero
+          end
+          rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :csv, rules_proc)
+          rules_collection.add_item_compilation_rule(rule)
+        end
+
+        example do
+          subject
+
+          expect(subject[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[0].snapshot_name).to eql(:raw)
+          expect(subject[0].path).to be_nil
+
+          expect(subject[1]).to be_a(Nanoc::Int::ProcessingActions::Filter)
+          expect(subject[1].filter_name).to eql(:erb)
+          expect(subject[1].params).to eql(speed: :over_9000)
+
+          expect(subject[2]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[2].snapshot_name).to eql(:pre)
+          expect(subject[2].path).to be_nil
+
+          expect(subject[3]).to be_a(Nanoc::Int::ProcessingActions::Layout)
+          expect(subject[3].layout_identifier).to eql('/default.*')
+          expect(subject[3].params).to be_nil
+
+          expect(subject[4]).to be_a(Nanoc::Int::ProcessingActions::Filter)
+          expect(subject[4].filter_name).to eql(:typohero)
+          expect(subject[4].params).to eql({})
+
+          expect(subject[5]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[5].snapshot_name).to eql(:post)
+          expect(subject[5].path).to be_nil
+
+          expect(subject[6]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[6].snapshot_name).to eql(:last)
+          expect(subject[6].path).to be_nil
+
+          expect(subject.size).to eql(7)
+        end
+      end
+
+      context 'no routing rule exists' do
+        before do
+          # Add compilation rule
+          compilation_rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :csv, proc {})
+          rules_collection.add_item_compilation_rule(compilation_rule)
+        end
+
+        example do
+          subject
+
+          expect(subject[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[0].snapshot_name).to eql(:raw)
+          expect(subject[0].path).to be_nil
+
+          expect(subject[1]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[1].snapshot_name).to eql(:last)
+          expect(subject[1].path).to be_nil
+
+          expect(subject.size).to eql(2)
+        end
+      end
+
+      context 'routing rule exists' do
+        before do
+          # Add compilation rule
+          compilation_rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :csv, proc {})
+          rules_collection.add_item_compilation_rule(compilation_rule)
+
+          # Add routing rule
+          routing_rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :csv, proc { '/foo.md' }, snapshot_name: :last)
+          rules_collection.add_item_routing_rule(routing_rule)
+        end
+
+        example do
+          subject
+
+          expect(subject[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[0].snapshot_name).to eql(:raw)
+          expect(subject[0].path).to be_nil
+
+          expect(subject[1]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[1].snapshot_name).to eql(:last)
+          expect(subject[1].path).to eq('/foo.md')
+
+          expect(subject.size).to eql(2)
+        end
+      end
+
+      context 'routing rule for other rep exists' do
+        before do
+          # Add compilation rule
+          compilation_rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :csv, proc {})
+          rules_collection.add_item_compilation_rule(compilation_rule)
+
+          # Add routing rule
+          routing_rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :abc, proc { '/foo.md' }, snapshot_name: :last)
+          rules_collection.add_item_routing_rule(routing_rule)
+        end
+
+        example do
+          subject
+
+          expect(subject[0]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[0].snapshot_name).to eql(:raw)
+          expect(subject[0].path).to be_nil
+
+          expect(subject[1]).to be_a(Nanoc::Int::ProcessingActions::Snapshot)
+          expect(subject[1].snapshot_name).to eql(:last)
+          expect(subject[1].path).to be_nil
+
+          expect(subject.size).to eql(2)
+        end
+      end
+    end
+
+    context 'with layout' do
+      let(:obj) { Nanoc::Int::Layout.new('content', {}, '/default.erb') }
+
+      context 'no rules exist' do
+        it 'raises error' do
+          error = Nanoc::RuleDSL::RuleMemoryCalculator::NoRuleMemoryForLayoutException
+          expect { subject }.to raise_error(error)
+        end
+      end
+
+      context 'rule exists' do
+        before do
+          pat = Nanoc::Int::Pattern.from('/*.erb')
+          rules_collection.layout_filter_mapping[pat] = [:erb, { x: 123 }]
+        end
+
+        it 'contains memory for the rule' do
+          expect(subject.size).to eql(1)
+          expect(subject[0]).to be_a(Nanoc::Int::ProcessingActions::Filter)
+          expect(subject[0].filter_name).to eql(:erb)
+          expect(subject[0].params).to eql(x: 123)
+        end
+      end
+    end
+
+    context 'with something else' do
+      let(:obj) { :donkey }
+
+      it 'errors' do
+        error = Nanoc::RuleDSL::RuleMemoryCalculator::UnsupportedObjectTypeException
+        expect { subject }.to raise_error(error)
+      end
+    end
+  end
+
+  describe '#snapshots_defs_for' do
+    subject { rule_memory_calculator.snapshots_defs_for(rep) }
+
+    let(:rep) { Nanoc::Int::ItemRep.new(item, :csv) }
+
+    let(:item) { Nanoc::Int::Item.new('content', {}, Nanoc::Identifier.from('/list.md')) }
+    let(:config) { Nanoc::Int::Configuration.new.with_defaults }
+    let(:items) { Nanoc::Int::IdentifiableCollection.new(config) }
+    let(:layouts) { Nanoc::Int::IdentifiableCollection.new(config) }
+    let(:site) { double(:site, items: items, layouts: layouts, config: config, compiler: compiler) }
+    let(:compiler) { double(:compiler, compilation_context: compilation_context) }
+    let(:compilation_context) { double(:compilation_context) }
+    let(:view_context) { double(:view_context) }
+
+    before do
+      rules_proc = proc do
+        filter :erb, speed: :over_9000
+        layout '/default.*'
+        filter :typohero
+      end
+      rule = Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/list.*'), :csv, rules_proc)
+      rules_collection.add_item_compilation_rule(rule)
+
+      expect(compilation_context).to receive(:create_view_context).and_return(view_context)
+    end
+
+    example do
+      expect(subject[0]).to be_a(Nanoc::Int::SnapshotDef)
+      expect(subject[0].name).to eql(:raw)
+
+      expect(subject[1]).to be_a(Nanoc::Int::SnapshotDef)
+      expect(subject[1].name).to eql(:pre)
+
+      expect(subject[2]).to be_a(Nanoc::Int::SnapshotDef)
+      expect(subject[2].name).to eql(:post)
+
+      expect(subject[3]).to be_a(Nanoc::Int::SnapshotDef)
+      expect(subject[3].name).to eql(:last)
+
+      expect(subject.size).to eql(4)
+    end
+  end
+end
diff --git a/spec/nanoc/rule_dsl/rules_collection_spec.rb b/spec/nanoc/rule_dsl/rules_collection_spec.rb
new file mode 100644
index 0000000..85c721c
--- /dev/null
+++ b/spec/nanoc/rule_dsl/rules_collection_spec.rb
@@ -0,0 +1,299 @@
+describe Nanoc::RuleDSL::RulesCollection do
+  let(:rules_collection) { described_class.new }
+
+  describe '#data' do
+    subject { rules_collection.data }
+
+    it 'is nil by default' do
+      expect(subject).to be_nil
+    end
+
+    it 'can be set' do
+      rules_collection.data = 'asdf'
+      expect(subject).to eq('asdf')
+    end
+  end
+
+  describe '#compilation_rule_for' do
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/foo.md') }
+
+    let(:rep) { Nanoc::Int::ItemRep.new(item, rep_name) }
+
+    let(:rep_name) { :default }
+
+    subject { rules_collection.compilation_rule_for(rep) }
+
+    context 'no rules' do
+      it 'is nil' do
+        expect(subject).to be_nil
+      end
+    end
+
+    context 'some rules, none matching' do
+      before do
+        rules_collection.add_item_compilation_rule(rule)
+      end
+
+      let(:rule) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/bar.*'), :default, proc {})
+      end
+
+      it 'is nil' do
+        expect(subject).to be_nil
+      end
+    end
+
+    context 'some rules, one matching' do
+      before do
+        rules_collection.add_item_compilation_rule(rule_a)
+        rules_collection.add_item_compilation_rule(rule_b)
+      end
+
+      let(:rule_a) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/foo.*'), :default, proc {})
+      end
+
+      let(:rule_b) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/bar.*'), :default, proc {})
+      end
+
+      context 'rep name does not match' do
+        let(:rep_name) { :platypus }
+
+        it 'is nil' do
+          expect(subject).to be_nil
+        end
+      end
+
+      context 'rep name matches' do
+        it 'is the rule' do
+          expect(subject).to equal(rule_a)
+        end
+      end
+    end
+
+    context 'some rules, multiple matching' do
+      before do
+        rules_collection.add_item_compilation_rule(rule_a)
+        rules_collection.add_item_compilation_rule(rule_b)
+        rules_collection.add_item_compilation_rule(rule_c)
+      end
+
+      let(:rule_a) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/foo.*'), :default, proc {})
+      end
+
+      let(:rule_b) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/*.*'), :default, proc {})
+      end
+
+      let(:rule_c) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/*.*'), :foo, proc {})
+      end
+
+      context 'no rep name matches' do
+        let(:rep_name) { :platypus }
+
+        it 'is the first matching rule' do
+          expect(subject).to be_nil
+        end
+      end
+
+      context 'one rep name matches' do
+        let(:rep_name) { :foo }
+
+        it 'is the first matching rule' do
+          expect(subject).to equal(rule_c)
+        end
+      end
+
+      context 'multiple rep names match' do
+        it 'is the first matching rule' do
+          expect(subject).to equal(rule_a)
+        end
+      end
+    end
+  end
+
+  describe '#item_compilation_rules_for' do
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/foo.md') }
+
+    subject { rules_collection.item_compilation_rules_for(item) }
+
+    context 'no rules' do
+      it 'is none' do
+        expect(subject).to be_empty
+      end
+    end
+
+    context 'some rules, none matching' do
+      before do
+        rules_collection.add_item_compilation_rule(rule)
+      end
+
+      let(:rule) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/bar.*'), :default, proc {})
+      end
+
+      it 'is none' do
+        expect(subject).to be_empty
+      end
+    end
+
+    context 'some rules, one matching' do
+      before do
+        rules_collection.add_item_compilation_rule(rule_a)
+        rules_collection.add_item_compilation_rule(rule_b)
+      end
+
+      let(:rule_a) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/foo.*'), :default, proc {})
+      end
+
+      let(:rule_b) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/bar.*'), :default, proc {})
+      end
+
+      it 'is the single rule' do
+        expect(subject).to contain_exactly(rule_a)
+      end
+    end
+
+    context 'some rules, multiple matching' do
+      before do
+        rules_collection.add_item_compilation_rule(rule_a)
+        rules_collection.add_item_compilation_rule(rule_b)
+      end
+
+      let(:rule_a) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/foo.*'), :default, proc {})
+      end
+
+      let(:rule_b) do
+        Nanoc::RuleDSL::Rule.new(Nanoc::Int::Pattern.from('/*.*'), :default, proc {})
+      end
+
+      it 'is all matching rule' do
+        expect(subject).to contain_exactly(rule_a, rule_b)
+      end
+    end
+  end
+
+  describe '#routing_rules_for' do
+    let(:item) { Nanoc::Int::Item.new('content', {}, '/foo.md') }
+
+    let(:rep) { Nanoc::Int::ItemRep.new(item, :default) }
+
+    subject { rules_collection.routing_rules_for(rep) }
+
+    let(:rules) do
+      [
+        # Matching item, matching rep
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/foo.*'), :default, proc {}, snapshot_name: :a
+        ),
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/foo.*'), :default, proc {}, snapshot_name: :b
+        ),
+
+        # Matching item, non-matching rep
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/foo.*'), :raw, proc {}, snapshot_name: :a
+        ),
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/foo.*'), :raw, proc {}, snapshot_name: :b
+        ),
+
+        # Non-matching item, matching rep
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/bar.*'), :default, proc {}, snapshot_name: :a
+        ),
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/bar.*'), :default, proc {}, snapshot_name: :b
+        ),
+
+        # Non-matching item, non-matching rep
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/bar.*'), :raw, proc {}, snapshot_name: :a
+        ),
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/bar.*'), :raw, proc {}, snapshot_name: :b
+        ),
+
+        # Matching item, matching rep, but not the first
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/*.*'), :default, proc {}, snapshot_name: :a
+        ),
+        Nanoc::RuleDSL::Rule.new(
+          Nanoc::Int::Pattern.from('/*.*'), :default, proc {}, snapshot_name: :b
+        ),
+      ]
+    end
+
+    before do
+      rules.each do |rule|
+        rules_collection.add_item_routing_rule(rule)
+      end
+    end
+
+    it 'returns the first matching rule for every snapshot' do
+      expect(subject).to eq(
+        a: rules[0],
+        b: rules[1],
+      )
+    end
+  end
+
+  describe '#filter_for_layout' do
+    let(:layout) { Nanoc::Int::Layout.new('Some content', {}, '/foo.md') }
+
+    subject { rules_collection.filter_for_layout(layout) }
+
+    let(:mapping) { {} }
+
+    before do
+      mapping.each_pair do |key, value|
+        rules_collection.layout_filter_mapping[Nanoc::Int::Pattern.from(key)] = value
+      end
+    end
+
+    context 'no rules' do
+      it { is_expected.to be_nil }
+    end
+
+    context 'one non-matching rule' do
+      let(:mapping) do
+        {
+          '/default.*' => [:erb, {}],
+        }
+      end
+
+      it { is_expected.to be_nil }
+    end
+
+    context 'one matching rule' do
+      let(:mapping) do
+        {
+          '/foo.*' => [:erb, {}],
+        }
+      end
+
+      it 'is the single one' do
+        expect(subject).to eq([:erb, {}])
+      end
+    end
+
+    context 'multiple matching rules' do
+      let(:mapping) do
+        {
+          '/foo.*' => [:erb, {}],
+          '/*' => [:haml, {}],
+        }
+      end
+
+      it 'is the first one' do
+        expect(subject).to eq([:erb, {}])
+      end
+    end
+  end
+end
diff --git a/spec/regression_filenames_spec.rb b/spec/regression_filenames_spec.rb
new file mode 100644
index 0000000..a82e3ae
--- /dev/null
+++ b/spec/regression_filenames_spec.rb
@@ -0,0 +1,16 @@
+describe 'regression tests', chdir: false do
+  let(:regression_test_filenames) do
+    Dir['spec/nanoc/regressions/*']
+  end
+
+  let(:regression_test_numbers) do
+    regression_test_filenames
+      .map { |fn| File.readlines(fn).first.match(/GH-(\d+)/)[1] }
+  end
+
+  it 'should have the proper filenames' do
+    regression_test_filenames.zip(regression_test_numbers) do |fn, num|
+      expect(fn).to match(/gh_#{num}[a-z]*_spec/), "#{fn} has the wrong name in its #define block"
+    end
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..85bd899
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,173 @@
+require 'simplecov'
+SimpleCov.start
+
+require 'nanoc'
+require 'nanoc/cli'
+require 'nanoc/spec'
+
+require 'timecop'
+require 'rspec/its'
+
+Nanoc::CLI.setup
+
+RSpec.configure do |c|
+  c.around(:each) do |example|
+    Nanoc::CLI::ErrorHandler.disable
+    example.run
+    Nanoc::CLI::ErrorHandler.enable
+  end
+
+  c.around(:each) do |example|
+    Dir.mktmpdir('nanoc-test') do |dir|
+      FileUtils.cd(dir) do
+        example.run
+      end
+    end
+  end
+
+  c.around(:each, chdir: false) do |example|
+    FileUtils.cd(File.dirname(__FILE__) + '/..') do
+      example.run
+    end
+  end
+
+  c.before(:each) do
+    Nanoc::Int::NotificationCenter.reset
+  end
+
+  c.before(:each, v8: true) do
+    if ENV.key?('DISABLE_V8')
+      skip 'V8 specs are disabled (broken on Ruby 2.4)'
+    end
+  end
+
+  c.around(:each, stdio: true) do |example|
+    orig_stdout = $stdout
+    orig_stderr = $stderr
+
+    unless ENV['QUIET'] == 'false'
+      $stdout = StringIO.new
+      $stderr = StringIO.new
+    end
+
+    example.run
+
+    $stdout = orig_stdout
+    $stderr = orig_stderr
+  end
+
+  c.before(:each, site: true) do
+    FileUtils.mkdir_p('content')
+    FileUtils.mkdir_p('layouts')
+    FileUtils.mkdir_p('lib')
+    FileUtils.mkdir_p('output')
+
+    File.write('nanoc.yaml', '{}')
+
+    File.write('Rules', 'passthrough "/**/*"')
+  end
+
+  c.include(Nanoc::Spec::HelperHelper, helper: true)
+
+  # Set focus if any
+  if ENV.fetch('FOCUS', false)
+    $stdout.puts "Focusing spec on '#{ENV['FOCUS']}'"
+    c.filter_run_including ENV['FOCUS'].to_sym => true
+  end
+end
+
+RSpec::Matchers.define :raise_frozen_error do |_expected|
+  match do |actual|
+    begin
+      actual.call
+      false
+    rescue => e
+      if e.is_a?(RuntimeError) || e.is_a?(TypeError)
+        e.message =~ /(^can't modify frozen |^unable to modify frozen object$)/
+      else
+        false
+      end
+    end
+  end
+
+  supports_block_expectations
+
+  failure_message do |_actual|
+    'expected that proc would raise a frozen error'
+  end
+
+  failure_message_when_negated do |_actual|
+    'expected that proc would not raise a frozen error'
+  end
+end
+
+RSpec::Matchers.define :be_humanly_sorted do
+  match do |actual|
+    actual == sort(actual)
+  end
+
+  description do
+    'be humanly sorted'
+  end
+
+  failure_message do |actual|
+    expected_order = []
+    actual.zip(sort(actual)).each do |a, b|
+      if a != b
+        expected_order << b
+      end
+    end
+
+    "expected collection to be sorted (incorrect order: #{expected_order.join(' < ')})"
+  end
+
+  def sort(x)
+    x.sort_by { |n| n.dup.unicode_normalize(:nfd).encode('ASCII', fallback: ->(_) { '' }).downcase }
+  end
+end
+
+RSpec::Matchers.define :finish_in_under do |expected|
+  supports_block_expectations
+
+  match do |actual|
+    before = Time.now
+    actual.call
+    after = Time.now
+    @actual_duration = after - before
+    @actual_duration < expected
+  end
+
+  chain :seconds do
+  end
+
+  failure_message do |_actual|
+    "expected that proc would finish in under #{expected}s, but took #{format '%0.1fs', @actual_duration}"
+  end
+
+  failure_message_when_negated do |_actual|
+    "expected that proc would not finish in under #{expected}s, but took #{format '%0.1fs', @actual_duration}"
+  end
+end
+
+RSpec::Matchers.define :yield_from_fiber do |expected|
+  supports_block_expectations
+
+  include RSpec::Matchers::Composable
+
+  match do |actual|
+    @res = Fiber.new { actual.call }.resume
+    values_match?(expected, @res)
+  end
+
+  description do
+    "yield #{expected.inspect} from fiber"
+  end
+
+  failure_message do |_actual|
+    "expected that proc would yield #{expected.inspect} from fiber, but was #{@res.inspect}"
+  end
+
+  failure_message_when_negated do |_actual|
+    "expected that proc would not yield #{expected.inspect} from fiber, but was #{@res.inspect}"
+  end
+end
diff --git a/tasks/doc.rake b/tasks/doc.rake
deleted file mode 100644
index bf69f74..0000000
--- a/tasks/doc.rake
+++ /dev/null
@@ -1,16 +0,0 @@
-require 'yard'
-
-YARD::Rake::YardocTask.new(:doc) do |yard|
-  yard.files   = Dir['lib/**/*.rb']
-  yard.options = [
-    '--markup',          'markdown',
-    '--markup-provider', 'kramdown',
-    '--charset',         'utf-8',
-    '--readme',          'README.md',
-    '--files',           'NEWS.md,LICENSE',
-    '--output-dir',      'doc/yardoc',
-    '--template-path',   'doc/yardoc_templates',
-    '--load',            'doc/yardoc_handlers/identifier.rb',
-    '--query',           '@api.text != "private"'
-  ]
-end
diff --git a/tasks/rubocop.rake b/tasks/rubocop.rake
deleted file mode 100644
index 2b576b6..0000000
--- a/tasks/rubocop.rake
+++ /dev/null
@@ -1,6 +0,0 @@
-require 'rubocop/rake_task'
-
-RuboCop::RakeTask.new(:rubocop) do |task|
-  task.options  = %w(--display-cop-names --format simple)
-  task.patterns = ['bin/nanoc', 'lib/**/*.rb', 'spec/**/*.rb', 'test/**/*.rb']
-end
diff --git a/tasks/test.rake b/tasks/test.rake
deleted file mode 100644
index f8180ce..0000000
--- a/tasks/test.rake
+++ /dev/null
@@ -1,25 +0,0 @@
-require 'rspec/core/rake_task'
-require 'rake/testtask'
-require 'coveralls/rake/task'
-
-Coveralls::RakeTask.new
-
-SUBDIRS = %w(* base cli data_sources extra filters helpers).freeze
-
-namespace :test do
-  SUBDIRS.each do |dir|
-    Rake::TestTask.new(dir == '*' ? 'all' : dir) do |t|
-      t.test_files = Dir["test/#{dir}/**/*_spec.rb"] + Dir["test/#{dir}/**/test_*.rb"]
-      t.libs = ['./lib', '.']
-      t.ruby_opts = ['-r./test/helper']
-    end
-  end
-end
-
-RSpec::Core::RakeTask.new(:spec) do |t|
-  t.rspec_opts = '-r ./spec/spec_helper.rb --format Fuubar --color'
-  t.verbose = false
-end
-
-desc 'Run all tests and specs'
-task test: [:spec, :'test:all', :'coveralls:push']
diff --git a/test/base/core_ext/array_spec.rb b/test/base/core_ext/array_spec.rb
index 9dbaade..ea39478 100644
--- a/test/base/core_ext/array_spec.rb
+++ b/test/base/core_ext/array_spec.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 describe 'Array#__nanoc_symbolize_keys_recursively' do
   it 'should convert keys to symbols' do
     array_old = [:abc, 'xyz', { 'foo' => 'bar', :baz => :qux }]
diff --git a/test/base/core_ext/hash_spec.rb b/test/base/core_ext/hash_spec.rb
index 1dfca0e..6925eb6 100644
--- a/test/base/core_ext/hash_spec.rb
+++ b/test/base/core_ext/hash_spec.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 describe 'Hash#__nanoc_symbolize_keys_recursively' do
   it 'should convert keys to symbols' do
     hash_old = { 'foo' => 'bar' }
diff --git a/test/base/core_ext/string_spec.rb b/test/base/core_ext/string_spec.rb
index b281fb7..028b620 100644
--- a/test/base/core_ext/string_spec.rb
+++ b/test/base/core_ext/string_spec.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 describe 'String#__nanoc_cleaned_identifier' do
   it 'should not convert already clean paths' do
     '/foo/bar/'.__nanoc_cleaned_identifier.must_equal '/foo/bar/'
diff --git a/test/base/temp_filename_factory_spec.rb b/test/base/temp_filename_factory_spec.rb
deleted file mode 100644
index 9adc431..0000000
--- a/test/base/temp_filename_factory_spec.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-describe Nanoc::Int::TempFilenameFactory do
-  subject do
-    Nanoc::Int::TempFilenameFactory.new
-  end
-
-  let(:prefix) { 'foo' }
-
-  describe '#create' do
-    it 'should create unique paths' do
-      path_a = subject.create(prefix)
-      path_b = subject.create(prefix)
-      path_a.wont_equal(path_b)
-    end
-
-    it 'should return absolute paths' do
-      path = subject.create(prefix)
-      path.must_match(/\A\//)
-    end
-
-    it 'should create the containing directory' do
-      Dir[subject.root_dir + '/**/*'].must_equal([])
-      path = subject.create(prefix)
-      File.directory?(File.dirname(path)).must_equal(true)
-    end
-
-    it 'should reuse the same path after cleanup' do
-      path_a = subject.create(prefix)
-      subject.cleanup(prefix)
-      path_b = subject.create(prefix)
-      path_a.must_equal(path_b)
-    end
-  end
-
-  describe '#cleanup' do
-    it 'should remove generated files' do
-      path_a = subject.create(prefix)
-      File.file?(path_a).wont_equal(true) # not yet used
-
-      File.open(path_a, 'w') { |io| io << 'hi!' }
-      File.file?(path_a).must_equal(true)
-
-      subject.cleanup(prefix)
-      File.file?(path_a).wont_equal(true)
-    end
-
-    it 'should eventually delete the root directory' do
-      subject.create(prefix)
-      File.directory?(subject.root_dir).must_equal(true)
-
-      subject.cleanup(prefix)
-      File.directory?(subject.root_dir).wont_equal(true)
-    end
-  end
-
-  describe 'other instance' do
-    let(:other_instance) do
-      Nanoc::Int::TempFilenameFactory.new
-    end
-
-    it 'should create unique paths across instances' do
-      path_a = subject.create(prefix)
-      path_b = other_instance.create(prefix)
-      path_a.wont_equal(path_b)
-    end
-  end
-end
diff --git a/test/base/test_checksum_store.rb b/test/base/test_checksum_store.rb
deleted file mode 100644
index a3785b0..0000000
--- a/test/base/test_checksum_store.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-class Nanoc::Int::ChecksumStoreTest < Nanoc::TestCase
-  def test_get_with_existing_object
-    require 'pstore'
-
-    # Create store
-    FileUtils.mkdir_p('tmp')
-    pstore = PStore.new('tmp/checksums')
-    pstore.transaction do
-      pstore[:data] = { [:item, '/moo/'] => 'zomg' }
-      pstore[:version] = 1
-    end
-
-    # Check
-    store = Nanoc::Int::ChecksumStore.new
-    store.load
-    obj = Nanoc::Int::Item.new('Moo?', {}, '/moo/')
-    assert_equal 'zomg', store[obj]
-  end
-
-  def test_get_with_nonexistant_object
-    store = Nanoc::Int::ChecksumStore.new
-    store.load
-
-    # Check
-    obj = Nanoc::Int::Item.new('Moo?', {}, '/animals/cow/')
-    assert_equal nil, store[obj]
-  end
-end
diff --git a/test/base/test_code_snippet.rb b/test/base/test_code_snippet.rb
index 0edf3ad..49a2f12 100644
--- a/test/base/test_code_snippet.rb
+++ b/test/base/test_code_snippet.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::CodeSnippetTest < Nanoc::TestCase
   def test_load
     # Initialize
diff --git a/test/base/test_compiler.rb b/test/base/test_compiler.rb
index f8cfdff..17613bb 100644
--- a/test/base/test_compiler.rb
+++ b/test/base/test_compiler.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::CompilerTest < Nanoc::TestCase
   def new_compiler(site = nil)
     site ||= Nanoc::Int::Site.new(
@@ -11,9 +13,11 @@ class Nanoc::Int::CompilerTest < Nanoc::TestCase
 
     action_provider = Nanoc::Int::ActionProvider.named(:rule_dsl).for(site)
 
+    objects = site.items.to_a + site.layouts.to_a
+
     params = {
-      compiled_content_cache: Nanoc::Int::CompiledContentCache.new,
-      checksum_store: Nanoc::Int::ChecksumStore.new(site: site),
+      compiled_content_cache: Nanoc::Int::CompiledContentCache.new(items: site.items),
+      checksum_store: Nanoc::Int::ChecksumStore.new(site: site, objects: objects),
       rule_memory_store: Nanoc::Int::RuleMemoryStore.new,
       dependency_store: Nanoc::Int::DependencyStore.new(
         site.items.to_a + site.layouts.to_a,
@@ -208,21 +212,6 @@ class Nanoc::Int::CompilerTest < Nanoc::TestCase
     end
   end
 
-  def test_compile_should_recompile_all_reps
-    Nanoc::CLI.run %w(create_site bar)
-
-    FileUtils.cd('bar') do
-      Nanoc::CLI.run %w(compile)
-
-      site = Nanoc::Int::SiteLoader.new.new_from_cwd
-      site.compile
-
-      # At this point, even the already compiled items in the previous pass
-      # should have their compiled content assigned, so this should work:
-      site.compiler.reps[site.items['/index.*']][0].compiled_content
-    end
-  end
-
   def test_disallow_multiple_snapshots_with_the_same_name
     # Create site
     Nanoc::CLI.run %w(create_site bar)
diff --git a/test/base/test_context.rb b/test/base/test_context.rb
index a74b4d5..63b36f0 100644
--- a/test/base/test_context.rb
+++ b/test/base/test_context.rb
@@ -1,7 +1,9 @@
+require 'helper'
+
 class Nanoc::Int::ContextTest < Nanoc::TestCase
   def test_context_with_instance_variable
     # Create context
-    context = Nanoc::Int::Context.new({ foo: 'bar', baz: 'quux' })
+    context = Nanoc::Int::Context.new(foo: 'bar', baz: 'quux')
 
     # Ensure correct evaluation
     assert_equal('bar', eval('@foo', context.get_binding))
@@ -9,7 +11,7 @@ class Nanoc::Int::ContextTest < Nanoc::TestCase
 
   def test_context_with_instance_method
     # Create context
-    context = Nanoc::Int::Context.new({ foo: 'bar', baz: 'quux' })
+    context = Nanoc::Int::Context.new(foo: 'bar', baz: 'quux')
 
     # Ensure correct evaluation
     assert_equal('bar', eval('foo', context.get_binding))
@@ -17,9 +19,15 @@ class Nanoc::Int::ContextTest < Nanoc::TestCase
 
   def test_example
     # Parse
-    YARD.parse(LIB_DIR + '/nanoc/base/context.rb')
+    YARD.parse(LIB_DIR + '/nanoc/base/entities/context.rb')
 
     # Run
     assert_examples_correct 'Nanoc::Int::Context#initialize'
   end
+
+  def test_include
+    context = Nanoc::Int::Context.new({})
+    eval('include Nanoc::Helpers::HTMLEscape', context.get_binding)
+    assert_equal('<>', eval('h("<>")', context.get_binding))
+  end
 end
diff --git a/test/base/test_data_source.rb b/test/base/test_data_source.rb
index f527ebd..d2c95f9 100644
--- a/test/base/test_data_source.rb
+++ b/test/base/test_data_source.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::DataSourceTest < Nanoc::TestCase
   def test_loading
     # Create data source
@@ -40,6 +42,17 @@ class Nanoc::DataSourceTest < Nanoc::TestCase
     assert_equal 'abcdef', item.checksum_data
   end
 
+  def test_new_item_with_checksums
+    data_source = Nanoc::DataSource.new(nil, nil, nil, nil)
+
+    item = data_source.new_item('stuff', { title: 'Stuff!' }, '/asdf/', content_checksum_data: 'con-cs', attributes_checksum_data: 'attr-cs')
+    assert_equal 'stuff', item.content.string
+    assert_equal 'Stuff!', item.attributes[:title]
+    assert_equal Nanoc::Identifier.new('/asdf/'), item.identifier
+    assert_equal 'con-cs', item.content_checksum_data
+    assert_equal 'attr-cs', item.attributes_checksum_data
+  end
+
   def test_new_layout
     data_source = Nanoc::DataSource.new(nil, nil, nil, nil)
 
@@ -49,4 +62,15 @@ class Nanoc::DataSourceTest < Nanoc::TestCase
     assert_equal Nanoc::Identifier.new('/asdf/'), layout.identifier
     assert_equal 'abcdef', layout.checksum_data
   end
+
+  def test_new_layout_with_checksums
+    data_source = Nanoc::DataSource.new(nil, nil, nil, nil)
+
+    layout = data_source.new_layout('stuff', { title: 'Stuff!' }, '/asdf/', content_checksum_data: 'con-cs', attributes_checksum_data: 'attr-cs')
+    assert_equal 'stuff', layout.content.string
+    assert_equal 'Stuff!', layout.attributes[:title]
+    assert_equal Nanoc::Identifier.new('/asdf/'), layout.identifier
+    assert_equal 'con-cs', layout.content_checksum_data
+    assert_equal 'attr-cs', layout.attributes_checksum_data
+  end
 end
diff --git a/test/base/test_dependency_tracker.rb b/test/base/test_dependency_tracker.rb
index fd520fa..7eca473 100644
--- a/test/base/test_dependency_tracker.rb
+++ b/test/base/test_dependency_tracker.rb
@@ -1,7 +1,12 @@
+require 'helper'
+
 class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
   def test_initialize
     # Mock items
-    items = [mock, mock]
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -13,7 +18,10 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_record_dependency
     # Mock items
-    items = [mock, mock]
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -27,7 +35,10 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_record_dependency_no_self
     # Mock items
-    items = [mock, mock]
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -42,7 +53,10 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_record_dependency_no_doubles
     # Mock items
-    items = [mock, mock]
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -58,7 +72,11 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_objects_causing_outdatedness_of
     # Mock items
-    items = [mock, mock, mock]
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+      Nanoc::Int::Item.new('c', {}, '/c.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -71,48 +89,15 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
     assert_contains_exactly [items[1]], store.objects_causing_outdatedness_of(items[0])
   end
 
-  def test_objects_outdated_due_to
+  def test_store_graph_and_load_graph_simple
     # Mock items
-    items = [mock, mock, mock]
-
-    # Create
-    store = Nanoc::Int::DependencyStore.new(items)
-
-    # Record some dependencies
-    store.record_dependency(items[0], items[1])
-    store.record_dependency(items[1], items[2])
-
-    # Verify dependencies
-    assert_contains_exactly [items[0]], store.objects_outdated_due_to(items[1])
-  end
-
-  def test_enter_and_exit
     items = [
-      Nanoc::Int::Item.new('Foo', {}, '/foo.md'),
-      Nanoc::Int::Item.new('Bar', {}, '/bar.md'),
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+      Nanoc::Int::Item.new('c', {}, '/c.md'),
+      Nanoc::Int::Item.new('d', {}, '/d.md'),
     ]
 
-    store = Nanoc::Int::DependencyStore.new(items)
-    tracker = Nanoc::Int::DependencyTracker.new(store)
-
-    tracker.enter(items[0])
-    tracker.enter(items[1])
-    tracker.exit(items[1])
-    tracker.exit(items[0])
-
-    assert_contains_exactly [items[1]], store.objects_causing_outdatedness_of(items[0])
-    assert_empty store.objects_causing_outdatedness_of(items[1])
-  end
-
-  def test_store_graph_and_load_graph_simple
-    # Mock items
-    items = [mock('0'), mock('1'), mock('2'), mock('3')]
-    items.each { |i| i.stubs(:type).returns(:item) }
-    items[0].stubs(:reference).returns([:item, '/aaa/'])
-    items[1].stubs(:reference).returns([:item, '/bbb/'])
-    items[2].stubs(:reference).returns([:item, '/ccc/'])
-    items[3].stubs(:reference).returns([:item, '/ddd/'])
-
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
 
@@ -140,12 +125,12 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_store_graph_and_load_graph_with_removed_items
     # Mock items
-    items = [mock('0'), mock('1'), mock('2'), mock('3')]
-    items.each { |i| i.stubs(:type).returns(:item) }
-    items[0].stubs(:reference).returns([:item, '/aaa/'])
-    items[1].stubs(:reference).returns([:item, '/bbb/'])
-    items[2].stubs(:reference).returns([:item, '/ccc/'])
-    items[3].stubs(:reference).returns([:item, '/ddd/'])
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+      Nanoc::Int::Item.new('c', {}, '/c.md'),
+      Nanoc::Int::Item.new('d', {}, '/d.md'),
+    ]
 
     # Create new and old lists
     old_items = [items[0], items[1], items[2], items[3]]
@@ -177,11 +162,11 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_store_graph_with_nils_in_dst
     # Mock items
-    items = [mock('0'), mock('1'), mock('2')]
-    items.each { |i| i.stubs(:type).returns(:item) }
-    items[0].stubs(:reference).returns([:item, '/aaa/'])
-    items[1].stubs(:reference).returns([:item, '/bbb/'])
-    items[2].stubs(:reference).returns([:item, '/ccc/'])
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+      Nanoc::Int::Item.new('c', {}, '/c.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -207,11 +192,11 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_store_graph_with_nils_in_src
     # Mock items
-    items = [mock('0'), mock('1'), mock('2')]
-    items.each { |i| i.stubs(:type).returns(:item) }
-    items[0].stubs(:reference).returns([:item, '/aaa/'])
-    items[1].stubs(:reference).returns([:item, '/bbb/'])
-    items[2].stubs(:reference).returns([:item, '/ccc/'])
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+      Nanoc::Int::Item.new('c', {}, '/c.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
@@ -237,7 +222,11 @@ class Nanoc::Int::DependencyTrackerTest < Nanoc::TestCase
 
   def test_forget_dependencies_for
     # Mock items
-    items = [mock, mock, mock]
+    items = [
+      Nanoc::Int::Item.new('a', {}, '/a.md'),
+      Nanoc::Int::Item.new('b', {}, '/b.md'),
+      Nanoc::Int::Item.new('c', {}, '/c.md'),
+    ]
 
     # Create
     store = Nanoc::Int::DependencyStore.new(items)
diff --git a/test/base/test_directed_graph.rb b/test/base/test_directed_graph.rb
index 26070a6..5b81753 100644
--- a/test/base/test_directed_graph.rb
+++ b/test/base/test_directed_graph.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::DirectedGraphTest < Nanoc::TestCase
   def test_direct_predecessors
     graph = Nanoc::Int::DirectedGraph.new([1, 2, 3])
@@ -44,7 +46,7 @@ class Nanoc::Int::DirectedGraphTest < Nanoc::TestCase
     graph.add_edge(1, 2)
     graph.add_edge(2, 3)
 
-    assert_equal [[0, 1], [1, 2]], graph.edges.sort
+    assert_equal [[0, 1, nil], [1, 2, nil]], graph.edges.sort
   end
 
   def test_edges_with_new_vertices
@@ -55,7 +57,50 @@ class Nanoc::Int::DirectedGraphTest < Nanoc::TestCase
     graph.add_edge(3, 2)
     assert_equal [1, 2, 3], graph.vertices
 
-    assert_equal [[0, 1], [2, 1]], graph.edges.sort
+    assert_equal [[0, 1, nil], [2, 1, nil]], graph.edges.sort
+  end
+
+  def test_edge_with_props
+    graph = Nanoc::Int::DirectedGraph.new([1, 2, 3])
+    graph.add_edge(1, 2, props: { donkey: 14 })
+    graph.add_edge(2, 3, props: { giraffe: 3 })
+
+    assert_equal [[0, 1, { donkey: 14 }], [1, 2, { giraffe: 3 }]], graph.edges.sort
+  end
+
+  def test_props_for
+    graph = Nanoc::Int::DirectedGraph.new([1, 2, 3, 4])
+    graph.add_edge(1, 2, props: { donkey: 14 })
+    graph.add_edge(2, 3, props: { giraffe: 3 })
+    graph.add_edge(3, 4)
+
+    assert_equal({ donkey: 14 }, graph.props_for(1, 2))
+    assert_equal({ giraffe: 3 }, graph.props_for(2, 3))
+    assert_equal(nil, graph.props_for(3, 4))
+  end
+
+  def test_props_for_with_deleted_edge
+    graph = Nanoc::Int::DirectedGraph.new([1, 2])
+    graph.add_edge(1, 2, props: { donkey: 14 })
+    graph.delete_edge(1, 2)
+
+    assert_equal(nil, graph.props_for(1, 2))
+  end
+
+  def test_props_for_with_deleted_edges_from
+    graph = Nanoc::Int::DirectedGraph.new([1, 2])
+    graph.add_edge(1, 2, props: { donkey: 14 })
+    graph.delete_edges_from(1)
+
+    assert_equal(nil, graph.props_for(1, 2))
+  end
+
+  def test_props_for_with_deleted_edges_to
+    graph = Nanoc::Int::DirectedGraph.new([1, 2])
+    graph.add_edge(1, 2, props: { donkey: 14 })
+    graph.delete_edges_to(2)
+
+    assert_equal(nil, graph.props_for(1, 2))
   end
 
   def test_add_edge
@@ -279,7 +324,7 @@ class Nanoc::Int::DirectedGraphTest < Nanoc::TestCase
   end
 
   def test_example
-    YARD.parse(LIB_DIR + '/nanoc/base/directed_graph.rb')
+    YARD.parse(LIB_DIR + '/nanoc/base/entities/directed_graph.rb')
     assert_examples_correct 'Nanoc::Int::DirectedGraph'
   end
 end
diff --git a/test/base/test_filter.rb b/test/base/test_filter.rb
index abaa89a..f89af43 100644
--- a/test/base/test_filter.rb
+++ b/test/base/test_filter.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::FilterTest < Nanoc::TestCase
   def test_initialize
     # Create filter
@@ -9,7 +11,7 @@ class Nanoc::FilterTest < Nanoc::TestCase
 
   def test_assigns
     # Create filter
-    filter = Nanoc::Filter.new({ foo: 'bar' })
+    filter = Nanoc::Filter.new(foo: 'bar')
 
     # Check assigns
     assert_equal('bar', filter.assigns[:foo])
@@ -17,7 +19,7 @@ class Nanoc::FilterTest < Nanoc::TestCase
 
   def test_assigns_with_instance_variables
     # Create filter
-    filter = Nanoc::Filter.new({ foo: 'bar' })
+    filter = Nanoc::Filter.new(foo: 'bar')
 
     # Check assigns
     assert_equal('bar', filter.instance_eval { @foo })
@@ -25,7 +27,7 @@ class Nanoc::FilterTest < Nanoc::TestCase
 
   def test_assigns_with_instance_methods
     # Create filter
-    filter = Nanoc::Filter.new({ foo: 'bar' })
+    filter = Nanoc::Filter.new(foo: 'bar')
 
     # Check assigns
     assert_equal('bar', filter.instance_eval { foo })
@@ -49,7 +51,7 @@ class Nanoc::FilterTest < Nanoc::TestCase
     item_rep.expects(:name).returns(:quux)
 
     # Create filter
-    filter = Nanoc::Filter.new({ item: item, item_rep: item_rep })
+    filter = Nanoc::Filter.new(item: item, item_rep: item_rep)
 
     # Check filename
     assert_equal('item /foo/bar/baz/ (rep quux)', filter.filename)
@@ -61,7 +63,7 @@ class Nanoc::FilterTest < Nanoc::TestCase
     layout.expects(:identifier).returns('/wohba/')
 
     # Create filter
-    filter = Nanoc::Filter.new({ item: mock, item_rep: mock, layout: layout })
+    filter = Nanoc::Filter.new(item: mock, item_rep: mock, layout: layout)
 
     # Check filename
     assert_equal('layout /wohba/', filter.filename)
diff --git a/test/base/test_item.rb b/test/base/test_item.rb
index 2a9d355..ed7e88d 100644
--- a/test/base/test_item.rb
+++ b/test/base/test_item.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::ItemTest < Nanoc::TestCase
   def test_initialize_with_attributes_with_string_keys
     item = Nanoc::Int::Item.new('foo', { 'abc' => 'xyz' }, '/foo/')
diff --git a/test/base/test_item_array.rb b/test/base/test_item_array.rb
index 17932eb..b45a058 100644
--- a/test/base/test_item_array.rb
+++ b/test/base/test_item_array.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::IdentifiableCollectionTest < Nanoc::TestCase
   def setup
     super
@@ -25,7 +27,7 @@ class Nanoc::Int::IdentifiableCollectionTest < Nanoc::TestCase
   end
 
   def test_brackets_with_glob
-    @items = Nanoc::Int::IdentifiableCollection.new({ string_pattern_type: 'glob' })
+    @items = Nanoc::Int::IdentifiableCollection.new(string_pattern_type: 'glob')
     @items << @one
     @items << @two
 
diff --git a/test/base/test_item_rep.rb b/test/base/test_item_rep.rb
deleted file mode 100644
index 19bd191..0000000
--- a/test/base/test_item_rep.rb
+++ /dev/null
@@ -1,153 +0,0 @@
-class Nanoc::Int::ItemRepTest < Nanoc::TestCase
-  def test_compiled_content_with_only_last_available
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.snapshot_contents = {
-      last: Nanoc::Int::TextualContent.new('last content'),
-    }
-    rep.expects(:compiled?).returns(true)
-
-    # Check
-    assert_equal 'last content', rep.compiled_content
-  end
-
-  def test_compiled_content_with_pre_and_last_available
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.snapshot_contents = {
-      pre: Nanoc::Int::TextualContent.new('pre content'),
-      last: Nanoc::Int::TextualContent.new('last content'),
-    }
-    rep.expects(:compiled?).returns(true)
-
-    # Check
-    assert_equal 'pre content', rep.compiled_content
-  end
-
-  def test_compiled_content_with_custom_snapshot
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.snapshot_contents = {
-      pre: Nanoc::Int::TextualContent.new('pre content'),
-      last: Nanoc::Int::TextualContent.new('last content'),
-    }
-    rep.expects(:compiled?).returns(true)
-
-    # Check
-    assert_equal 'last content', rep.compiled_content(snapshot: :last)
-  end
-
-  def test_compiled_content_with_invalid_snapshot
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.snapshot_contents = {
-      pre: Nanoc::Int::TextualContent.new('pre content'),
-      last: Nanoc::Int::TextualContent.new('last content'),
-    }
-
-    # Check
-    assert_raises Nanoc::Int::Errors::NoSuchSnapshot do
-      rep.compiled_content(snapshot: :klsjflkasdfl)
-    end
-  end
-
-  def test_compiled_content_with_uncompiled_content
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.expects(:compiled?).returns(false)
-
-    # Check
-    assert_raises(Nanoc::Int::Errors::UnmetDependency) do
-      rep.compiled_content
-    end
-  end
-
-  def test_compiled_content_with_moving_pre_snapshot
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.expects(:compiled?).returns(false)
-    rep.snapshot_contents = {
-      pre: Nanoc::Int::TextualContent.new('pre!'),
-      last: Nanoc::Int::TextualContent.new('last!'),
-    }
-
-    # Check
-    assert_raises(Nanoc::Int::Errors::UnmetDependency) do
-      rep.compiled_content(snapshot: :pre)
-    end
-  end
-
-  def test_compiled_content_with_non_moving_pre_snapshot
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.expects(:compiled?).returns(false)
-    rep.snapshot_defs = [
-      Nanoc::Int::SnapshotDef.new(:pre, true),
-    ]
-    rep.snapshot_contents = {
-      pre: Nanoc::Int::TextualContent.new('pre!'),
-      post: Nanoc::Int::TextualContent.new('post!'),
-      last: Nanoc::Int::TextualContent.new('last!'),
-    }
-
-    # Check
-    assert_equal 'pre!', rep.compiled_content(snapshot: :pre)
-  end
-
-  def test_compiled_content_with_multiple_pre_snapshots
-    # Create rep
-    item = Nanoc::Int::Item.new(
-      'blah blah', {}, '/'
-    )
-    rep = Nanoc::Int::ItemRep.new(item, :donkeys)
-    rep.expects(:compiled?).returns(false)
-    rep.snapshot_defs = [
-      Nanoc::Int::SnapshotDef.new(:pre, false),
-      Nanoc::Int::SnapshotDef.new(:pre, true),
-    ]
-    rep.snapshot_contents = {
-      pre: Nanoc::Int::TextualContent.new('pre!'),
-      post: Nanoc::Int::TextualContent.new('post!'),
-      last: Nanoc::Int::TextualContent.new('last!'),
-    }
-
-    # Check
-    assert_equal 'pre!', rep.compiled_content(snapshot: :pre)
-  end
-
-  def test_access_compiled_content_of_binary_item
-    content = Nanoc::Int::BinaryContent.new(File.expand_path('content/somefile.dat'))
-    item = Nanoc::Int::Item.new(content, {}, '/somefile/')
-    item_rep = Nanoc::Int::ItemRep.new(item, :foo)
-    assert_raises(Nanoc::Int::Errors::CannotGetCompiledContentOfBinaryItem) do
-      item_rep.compiled_content
-    end
-  end
-
-  private
-
-  def create_rep_for(item, name)
-    Nanoc::Int::ItemRep.new(item, name)
-  end
-end
diff --git a/test/base/test_layout.rb b/test/base/test_layout.rb
index 3c2dd19..42166f2 100644
--- a/test/base/test_layout.rb
+++ b/test/base/test_layout.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::LayoutTest < Nanoc::TestCase
   def test_initialize
     # Make sure attributes are cleaned
diff --git a/test/base/test_memoization.rb b/test/base/test_memoization.rb
index 187d451..87526d9 100644
--- a/test/base/test_memoization.rb
+++ b/test/base/test_memoization.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::MemoizationTest < Nanoc::TestCase
   class Sample1
     extend Nanoc::Int::Memoization
diff --git a/test/base/test_notification_center.rb b/test/base/test_notification_center.rb
index 6858ad7..7c080ae 100644
--- a/test/base/test_notification_center.rb
+++ b/test/base/test_notification_center.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::NotificationCenterTest < Nanoc::TestCase
   def test_post
     # Set up notification
diff --git a/test/base/test_outdatedness_checker.rb b/test/base/test_outdatedness_checker.rb
index ae0055c..6db356c 100644
--- a/test/base/test_outdatedness_checker.rb
+++ b/test/base/test_outdatedness_checker.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::OutdatednessCheckerTest < Nanoc::TestCase
   def test_not_outdated
     # Compile once
@@ -42,7 +44,7 @@ class Nanoc::Int::OutdatednessCheckerTest < Nanoc::TestCase
       site.compiler.load_stores
       outdatedness_checker = site.compiler.send :outdatedness_checker
       rep = site.compiler.reps[site.items.find { |i| i.identifier == '/' }][0]
-      assert_equal ::Nanoc::Int::OutdatednessReasons::NotEnoughData, outdatedness_checker.outdatedness_reason_for(rep)
+      assert_equal ::Nanoc::Int::OutdatednessReasons::ContentModified, outdatedness_checker.outdatedness_reason_for(rep)
     end
   end
 
@@ -72,7 +74,7 @@ class Nanoc::Int::OutdatednessCheckerTest < Nanoc::TestCase
     end
   end
 
-  def test_outdated_if_item_checksum_is_different
+  def test_outdated_if_item_content_checksum_is_different
     # Compile once
     with_site(name: 'foo') do |site|
       File.open('content/index.html', 'w') { |io| io.write('o hello') }
@@ -83,7 +85,7 @@ class Nanoc::Int::OutdatednessCheckerTest < Nanoc::TestCase
       site.compile
     end
 
-    # Create new item
+    # Update item
     FileUtils.cd('foo') do
       File.open('content/new.html', 'w') { |io| io.write('o hello DIFFERENT!!!') }
     end
@@ -95,7 +97,34 @@ class Nanoc::Int::OutdatednessCheckerTest < Nanoc::TestCase
       site.compiler.load_stores
       outdatedness_checker = site.compiler.send :outdatedness_checker
       rep = site.compiler.reps[site.items.find { |i| i.identifier == '/new/' }][0]
-      assert_equal ::Nanoc::Int::OutdatednessReasons::SourceModified, outdatedness_checker.outdatedness_reason_for(rep)
+      assert_equal ::Nanoc::Int::OutdatednessReasons::ContentModified, outdatedness_checker.outdatedness_reason_for(rep)
+    end
+  end
+
+  def test_outdated_if_item_attributes_checksum_is_different
+    # Compile once
+    with_site(name: 'foo') do |site|
+      File.open('content/index.html', 'w') { |io| io.write('o hello') }
+      File.open('content/new.html', 'w') { |io| io.write('o hello too') }
+      File.open('lib/stuff.rb', 'w') { |io| io.write('$foo = 123') }
+
+      site = Nanoc::Int::SiteLoader.new.new_from_cwd
+      site.compile
+    end
+
+    # Update item
+    FileUtils.cd('foo') do
+      File.open('content/new.html', 'w') { |io| io.write("---\ntitle: donkey\n---\no hello too") }
+    end
+
+    # Check
+    with_site(name: 'foo') do |site|
+      site = Nanoc::Int::SiteLoader.new.new_from_cwd
+      site.compiler.build_reps
+      site.compiler.load_stores
+      outdatedness_checker = site.compiler.send :outdatedness_checker
+      rep = site.compiler.reps[site.items.find { |i| i.identifier == '/new/' }][0]
+      assert_equal ::Nanoc::Int::OutdatednessReasons::AttributesModified, outdatedness_checker.outdatedness_reason_for(rep)
     end
   end
 
diff --git a/test/base/test_plugin.rb b/test/base/test_plugin.rb
index 3df2fdf..4165056 100644
--- a/test/base/test_plugin.rb
+++ b/test/base/test_plugin.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::PluginTest < Nanoc::TestCase
   class SampleFilter < Nanoc::Filter
     identifier :_plugin_test_sample_filter
diff --git a/test/base/test_site.rb b/test/base/test_site.rb
index 4bb05bf..0fcc3d5 100644
--- a/test/base/test_site.rb
+++ b/test/base/test_site.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::SiteTest < Nanoc::TestCase
   def test_initialize_with_dir_without_config_yaml
     assert_raises(Nanoc::Int::ConfigLoader::NoConfigFileFoundError) do
diff --git a/test/base/test_store.rb b/test/base/test_store.rb
index 049a6ca..3505a1d 100644
--- a/test/base/test_store.rb
+++ b/test/base/test_store.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::StoreTest < Nanoc::TestCase
   class TestStore < Nanoc::Int::Store
     def data
@@ -32,4 +34,26 @@ class Nanoc::Int::StoreTest < Nanoc::TestCase
     store.load
     assert_equal(nil, store.data)
   end
+
+  def test_tmp_path_with_nil_env
+    tmp_path_for_checksum = Nanoc::Int::Store.tmp_path_for(env_name: nil, store_name: 'checksum')
+    tmp_path_for_rule_memory = Nanoc::Int::Store.tmp_path_for(env_name: nil, store_name: 'rule_memory')
+    tmp_path_for_dependencies = Nanoc::Int::Store.tmp_path_for(env_name: nil, store_name: 'dependencies')
+    tmp_path_for_compiled_content = Nanoc::Int::Store.tmp_path_for(env_name: nil, store_name: 'compiled_content')
+    assert_equal('tmp/checksum', tmp_path_for_checksum)
+    assert_equal('tmp/rule_memory', tmp_path_for_rule_memory)
+    assert_equal('tmp/dependencies', tmp_path_for_dependencies)
+    assert_equal('tmp/compiled_content', tmp_path_for_compiled_content)
+  end
+
+  def test_tmp_path_with_test_env
+    tmp_path_for_checksum = Nanoc::Int::Store.tmp_path_for(env_name: 'test', store_name: 'checksum')
+    tmp_path_for_rule_memory = Nanoc::Int::Store.tmp_path_for(env_name: 'test', store_name: 'rule_memory')
+    tmp_path_for_dependencies = Nanoc::Int::Store.tmp_path_for(env_name: 'test', store_name: 'dependencies')
+    tmp_path_for_compiled_content = Nanoc::Int::Store.tmp_path_for(env_name: 'test', store_name: 'compiled_content')
+    assert_equal('tmp/test/checksum', tmp_path_for_checksum)
+    assert_equal('tmp/test/rule_memory', tmp_path_for_rule_memory)
+    assert_equal('tmp/test/dependencies', tmp_path_for_dependencies)
+    assert_equal('tmp/test/compiled_content', tmp_path_for_compiled_content)
+  end
 end
diff --git a/test/extra/checking/checks/test_css.rb b/test/checking/checks/test_css.rb
similarity index 86%
rename from test/extra/checking/checks/test_css.rb
rename to test/checking/checks/test_css.rb
index ac6d6cf..a65176c 100644
--- a/test/extra/checking/checks/test_css.rb
+++ b/test/checking/checks/test_css.rb
@@ -1,4 +1,6 @@
-class Nanoc::Extra::Checking::Checks::CSSTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::Checks::CSSTest < Nanoc::TestCase
   def test_run_ok
     VCR.use_cassette('css_run_ok') do
       with_site do |site|
@@ -8,7 +10,7 @@ class Nanoc::Extra::Checking::Checks::CSSTest < Nanoc::TestCase
         File.open('output/style.css', 'w') { |io| io.write('h1 { color: red; }') }
 
         # Run check
-        check = Nanoc::Extra::Checking::Checks::CSS.create(site)
+        check = Nanoc::Checking::Checks::CSS.create(site)
         check.run
 
         # Check
@@ -26,7 +28,7 @@ class Nanoc::Extra::Checking::Checks::CSSTest < Nanoc::TestCase
         File.open('output/style.css', 'w') { |io| io.write('h1 { coxlor: rxed; }') }
 
         # Run check
-        check = Nanoc::Extra::Checking::Checks::CSS.create(site)
+        check = Nanoc::Checking::Checks::CSS.create(site)
         check.run
 
         # Check
@@ -49,7 +51,7 @@ class Nanoc::Extra::Checking::Checks::CSSTest < Nanoc::TestCase
         File.open('output/style.css', 'w') { |io| io.write('h1 { ; {') }
 
         # Run check
-        check = Nanoc::Extra::Checking::Checks::CSS.create(site)
+        check = Nanoc::Checking::Checks::CSS.create(site)
         check.run
 
         # Check
diff --git a/test/extra/checking/checks/test_external_links.rb b/test/checking/checks/test_external_links.rb
similarity index 78%
rename from test/extra/checking/checks/test_external_links.rb
rename to test/checking/checks/test_external_links.rb
index 994df7d..bda8230 100644
--- a/test/extra/checking/checks/test_external_links.rb
+++ b/test/checking/checks/test_external_links.rb
@@ -1,4 +1,6 @@
-class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
   def test_run
     with_site do |site|
       # Create files
@@ -7,7 +9,7 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
       File.open('output/bar.html', 'w') { |io| io.write('<a href="http://example.com/">not broken</a>') }
 
       # Create check
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
       def check.request_url_once(url)
         Net::HTTPResponse.new('1.1', url.path == '/' ? '200' : '404', 'okay')
       end
@@ -21,7 +23,7 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
   def test_valid_by_path
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
       def check.request_url_once(url)
         Net::HTTPResponse.new('1.1', url.path == '/200' ? '200' : '404', 'okay')
       end
@@ -36,7 +38,7 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
   def test_valid_by_query
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
       def check.request_url_once(url)
         Net::HTTPResponse.new('1.1', url.query == 'status=200' ? '200' : '404', 'okay')
       end
@@ -50,7 +52,7 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
   def test_fallback_to_get_when_head_is_not_allowed
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
       def check.request_url_once(url, req_method = Net::HTTP::Head)
         Net::HTTPResponse.new('1.1', req_method == Net::HTTP::Head || url.path == '/405' ? '405' : '200', 'okay')
       end
@@ -63,7 +65,7 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
 
   def test_path_for_url
     with_site do |site|
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
 
       assert_equal '/',             check.send(:path_for_url, URI.parse('http://example.com'))
       assert_equal '/',             check.send(:path_for_url, URI.parse('http://example.com/'))
@@ -76,8 +78,8 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
   def test_excluded
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
-      site.config.update({ checks: { external_links: { exclude: ['^http://excluded.com$'] } } })
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
+      site.config.update(checks: { external_links: { exclude: ['^http://excluded.com$'] } })
 
       # Test
       assert check.send(:excluded?, 'http://excluded.com')
@@ -89,8 +91,8 @@ class Nanoc::Extra::Checking::Checks::ExternalLinksTest < Nanoc::TestCase
   def test_excluded_file
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::ExternalLinks.create(site)
-      site.config.update({ checks: { external_links: { exclude_files: ['blog/page'] } } })
+      check = Nanoc::Checking::Checks::ExternalLinks.create(site)
+      site.config.update(checks: { external_links: { exclude_files: ['blog/page'] } })
 
       # Test
       assert check.send(:excluded_file?, 'output/blog/page1/index.html')
diff --git a/test/extra/checking/checks/test_html.rb b/test/checking/checks/test_html.rb
similarity index 76%
rename from test/extra/checking/checks/test_html.rb
rename to test/checking/checks/test_html.rb
index e42badb..250482e 100644
--- a/test/extra/checking/checks/test_html.rb
+++ b/test/checking/checks/test_html.rb
@@ -1,5 +1,13 @@
-class Nanoc::Extra::Checking::Checks::HTMLTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::Checks::HTMLTest < Nanoc::TestCase
   def test_run_ok
+    require 'w3c_validators'
+
+    if ::W3CValidators::VERSION =~ /\A1\.3|1\.3\.1\z/
+      skip 'broken (see https://github.com/w3c-validators/w3c_validators/issues/25)'
+    end
+
     VCR.use_cassette('html_run_ok') do
       with_site do |site|
         # Create files
@@ -8,7 +16,7 @@ class Nanoc::Extra::Checking::Checks::HTMLTest < Nanoc::TestCase
         File.open('output/style.css', 'w') { |io| io.write('h1 { coxlor: rxed; }') }
 
         # Run check
-        check = Nanoc::Extra::Checking::Checks::HTML.create(site)
+        check = Nanoc::Checking::Checks::HTML.create(site)
         check.run
 
         # Check
@@ -26,7 +34,7 @@ class Nanoc::Extra::Checking::Checks::HTMLTest < Nanoc::TestCase
         File.open('output/style.css', 'w') { |io| io.write('h1 { coxlor: rxed; }') }
 
         # Run check
-        check = Nanoc::Extra::Checking::Checks::HTML.create(site)
+        check = Nanoc::Checking::Checks::HTML.create(site)
         check.run
 
         # Check
diff --git a/test/extra/checking/checks/test_internal_links.rb b/test/checking/checks/test_internal_links.rb
similarity index 75%
rename from test/extra/checking/checks/test_internal_links.rb
rename to test/checking/checks/test_internal_links.rb
index 45f2b9a..1106620 100644
--- a/test/extra/checking/checks/test_internal_links.rb
+++ b/test/checking/checks/test_internal_links.rb
@@ -1,4 +1,6 @@
-class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::Checks::InternalLinksTest < Nanoc::TestCase
   def test_run
     with_site do |site|
       # Create files
@@ -8,7 +10,7 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
       File.open('output/bar.html', 'w') { |io| io.write('<a href="/foo.txt">not broken</a>') }
 
       # Create check
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
       check.run
 
       # Test
@@ -23,7 +25,7 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
       File.open('output/bar.html', 'w') { |io| io.write('<link rel="stylesheet" href="/styledinges.css">') }
 
       # Create check
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
       check.run
 
       # Test
@@ -41,7 +43,7 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
       File.open('output/stuff/blah', 'w') { |io| io.write('hi') }
 
       # Create check
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
 
       # Test
       assert check.send(:valid?, 'foo',         'output/origin')
@@ -58,7 +60,7 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
       FileUtils.mkdir_p('output/stuff')
       File.open('output/stuff/right', 'w') { |io| io.write('hi') }
 
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
 
       assert check.send(:valid?, 'stuff/right?foo=123', 'output/origin')
       refute check.send(:valid?, 'stuff/wrong?foo=123', 'output/origin')
@@ -68,8 +70,8 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
   def test_exclude
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
-      site.config.update({ checks: { internal_links: { exclude: ['^/excluded\d+'] } } })
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
+      site.config.update(checks: { internal_links: { exclude: ['^/excluded\d+'] } })
 
       # Test
       assert check.send(:valid?, '/excluded1', 'output/origin')
@@ -81,8 +83,8 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
   def test_exclude_targets
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
-      site.config.update({ checks: { internal_links: { exclude_targets: ['^/excluded\d+'] } } })
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
+      site.config.update(checks: { internal_links: { exclude_targets: ['^/excluded\d+'] } })
 
       # Test
       assert check.send(:valid?, '/excluded1', 'output/origin')
@@ -94,8 +96,8 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
   def test_exclude_origins
     with_site do |site|
       # Create check
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
-      site.config.update({ checks: { internal_links: { exclude_origins: ['^/excluded'] } } })
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
+      site.config.update(checks: { internal_links: { exclude_origins: ['^/excluded'] } })
 
       # Test
       assert check.send(:valid?, '/foo', 'output/excluded')
@@ -108,7 +110,7 @@ class Nanoc::Extra::Checking::Checks::InternalLinksTest < Nanoc::TestCase
       FileUtils.mkdir_p('output/stuff')
       File.open('output/stuff/right foo', 'w') { |io| io.write('hi') }
 
-      check = Nanoc::Extra::Checking::Checks::InternalLinks.create(site)
+      check = Nanoc::Checking::Checks::InternalLinks.create(site)
 
       assert check.send(:valid?, 'stuff/right%20foo', 'output/origin')
       refute check.send(:valid?, 'stuff/wrong%20foo', 'output/origin')
diff --git a/test/extra/checking/checks/test_mixed_content.rb b/test/checking/checks/test_mixed_content.rb
similarity index 89%
rename from test/extra/checking/checks/test_mixed_content.rb
rename to test/checking/checks/test_mixed_content.rb
index 4574c9c..79ea031 100644
--- a/test/extra/checking/checks/test_mixed_content.rb
+++ b/test/checking/checks/test_mixed_content.rb
@@ -1,4 +1,6 @@
-class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::Checks::MixedContentTest < Nanoc::TestCase
   def create_output_file(name, lines)
     FileUtils.mkdir_p('output')
     File.open('output/' + name, 'w') do |io|
@@ -22,7 +24,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="https://nanoc.ws/theme-song.flac"></audio>',
         '<video src="https://nanoc.ws/screen-cast.mkv"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
@@ -40,7 +42,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="/theme-song.flac"></audio>',
         '<video src="/screen-cast.mkv"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
@@ -58,7 +60,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="//nanoc.ws/theme-song.flac"></audio>',
         '<video src="//nanoc.ws/screen-cast.mkv"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
@@ -76,7 +78,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="theme-song.flac"></audio>',
         '<video src="screen-cast.mkv"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
@@ -94,7 +96,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="?query-string"></audio>',
         '<video src="?query-string"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
@@ -112,7 +114,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="#fragment"></audio>',
         '<video src="#fragment"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
@@ -131,7 +133,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<audio src="http://nanoc.ws/theme-song.flac"></audio>',
         '<video src="http://nanoc.ws/screencast.mkv"></video>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       issues = check.issues.to_a
@@ -177,7 +179,7 @@ class Nanoc::Extra::Checking::Checks::MixedContentTest < Nanoc::TestCase
         '<video target="http://nanoc.ws/screen-cast.mkv"></video>',
         '<p>http://nanoc.ws/harmless-text</p>',
       ])
-      check = Nanoc::Extra::Checking::Checks::MixedContent.create(site)
+      check = Nanoc::Checking::Checks::MixedContent.create(site)
       check.run
 
       assert_empty check.issues
diff --git a/test/extra/checking/checks/test_stale.rb b/test/checking/checks/test_stale.rb
similarity index 92%
rename from test/extra/checking/checks/test_stale.rb
rename to test/checking/checks/test_stale.rb
index 0fde96f..16bf988 100644
--- a/test/extra/checking/checks/test_stale.rb
+++ b/test/checking/checks/test_stale.rb
@@ -1,11 +1,13 @@
-class Nanoc::Extra::Checking::Checks::StaleTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::Checks::StaleTest < Nanoc::TestCase
   def check_class
-    Nanoc::Extra::Checking::Checks::Stale
+    Nanoc::Checking::Checks::Stale
   end
 
   def calc_issues
     site = Nanoc::Int::SiteLoader.new.new_from_cwd
-    runner = Nanoc::Extra::Checking::Runner.new(site)
+    runner = Nanoc::Checking::Runner.new(site)
     runner.run_checks([check_class])
   end
 
diff --git a/test/extra/checking/test_check.rb b/test/checking/test_check.rb
similarity index 57%
rename from test/extra/checking/test_check.rb
rename to test/checking/test_check.rb
index d9c3a9f..1b614aa 100644
--- a/test/extra/checking/test_check.rb
+++ b/test/checking/test_check.rb
@@ -1,8 +1,10 @@
-class Nanoc::Extra::Checking::CheckTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::CheckTest < Nanoc::TestCase
   def test_output_filenames
     with_site do |site|
       File.open('output/foo.html', 'w') { |io| io.write 'hello' }
-      check = Nanoc::Extra::Checking::Check.create(site)
+      check = Nanoc::Checking::Check.create(site)
       assert_equal ['output/foo.html'], check.output_filenames
     end
   end
@@ -10,8 +12,8 @@ class Nanoc::Extra::Checking::CheckTest < Nanoc::TestCase
   def test_no_output_dir
     with_site do |site|
       site.config[:output_dir] = 'non-existent'
-      assert_raises Nanoc::Extra::Checking::OutputDirNotFoundError do
-        Nanoc::Extra::Checking::Check.create(site)
+      assert_raises Nanoc::Checking::OutputDirNotFoundError do
+        Nanoc::Checking::Check.create(site)
       end
     end
   end
diff --git a/test/extra/checking/test_dsl.rb b/test/checking/test_dsl.rb
similarity index 52%
rename from test/extra/checking/test_dsl.rb
rename to test/checking/test_dsl.rb
index 7ee0302..e4916cd 100644
--- a/test/extra/checking/test_dsl.rb
+++ b/test/checking/test_dsl.rb
@@ -1,11 +1,13 @@
-class Nanoc::Extra::Checking::DSLTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::DSLTest < Nanoc::TestCase
   def test_from_file
     with_site do |_site|
       File.open('Checks', 'w') { |io| io.write("check :foo do\n\nend\ndeploy_check :bar\n") }
-      dsl = Nanoc::Extra::Checking::DSL.from_file('Checks')
+      dsl = Nanoc::Checking::DSL.from_file('Checks')
 
       # One new check
-      refute Nanoc::Extra::Checking::Check.named(:foo).nil?
+      refute Nanoc::Checking::Check.named(:foo).nil?
 
       # One check marked for deployment
       assert_equal [:bar], dsl.deploy_checks
@@ -16,8 +18,16 @@ class Nanoc::Extra::Checking::DSLTest < Nanoc::TestCase
     with_site do |_site|
       File.write('stuff.rb', '$greeting = "hello"')
       File.write('Checks', 'require "./stuff"')
-      Nanoc::Extra::Checking::DSL.from_file('Checks')
+      Nanoc::Checking::DSL.from_file('Checks')
       assert_equal 'hello', $greeting
     end
   end
+
+  def test_has_absolute_path
+    with_site do |_site|
+      File.write('Checks', '$stuff = __FILE__')
+      Nanoc::Checking::DSL.from_file('Checks')
+      assert($stuff.start_with?('/'))
+    end
+  end
 end
diff --git a/test/extra/checking/test_runner.rb b/test/checking/test_runner.rb
similarity index 79%
rename from test/extra/checking/test_runner.rb
rename to test/checking/test_runner.rb
index d19f814..3a90c7a 100644
--- a/test/extra/checking/test_runner.rb
+++ b/test/checking/test_runner.rb
@@ -1,8 +1,10 @@
-class Nanoc::Extra::Checking::RunnerTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Checking::RunnerTest < Nanoc::TestCase
   def test_run_specific
     with_site do |site|
       File.open('output/blah', 'w') { |io| io.write('I am stale! Haha!') }
-      runner = Nanoc::Extra::Checking::Runner.new(site)
+      runner = Nanoc::Checking::Runner.new(site)
       runner.run_specific(%w(stale))
     end
   end
@@ -13,7 +15,7 @@ class Nanoc::Extra::Checking::RunnerTest < Nanoc::TestCase
         io.write('check :my_foo_check do ; puts "I AM FOO!" ; end')
       end
 
-      runner = Nanoc::Extra::Checking::Runner.new(site)
+      runner = Nanoc::Checking::Runner.new(site)
       ios = capturing_stdio do
         runner.run_specific(%w(my_foo_check))
       end
@@ -28,7 +30,7 @@ class Nanoc::Extra::Checking::RunnerTest < Nanoc::TestCase
         io.write('check :my_foo_check do ; end')
       end
 
-      runner = Nanoc::Extra::Checking::Runner.new(site)
+      runner = Nanoc::Checking::Runner.new(site)
       ios = capturing_stdio do
         runner.list_checks
       end
diff --git a/test/cli/commands/test_check.rb b/test/cli/commands/test_check.rb
index d75f756..faaf675 100644
--- a/test/cli/commands/test_check.rb
+++ b/test/cli/commands/test_check.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::Commands::CheckTest < Nanoc::TestCase
   def test_check_stale
     with_site do |_site|
diff --git a/test/cli/commands/test_compile.rb b/test/cli/commands/test_compile.rb
index d0f2695..cd0d4d5 100644
--- a/test/cli/commands/test_compile.rb
+++ b/test/cli/commands/test_compile.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::Commands::CompileTest < Nanoc::TestCase
   def test_profiling_information
     with_site do |_site|
diff --git a/test/cli/commands/test_create_site.rb b/test/cli/commands/test_create_site.rb
index 255f4ad..7616db8 100644
--- a/test/cli/commands/test_create_site.rb
+++ b/test/cli/commands/test_create_site.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::Commands::CreateSiteTest < Nanoc::TestCase
   def test_create_site_with_existing_name
     Nanoc::CLI.run %w(create_site foo)
@@ -75,7 +77,7 @@ class Nanoc::CLI::Commands::CreateSiteTest < Nanoc::TestCase
     FileUtils.cd('foo') do
       # Try with encoding = default encoding = utf-8
       File.open('content/index.html', 'w') { |io| io.write('Hello ' + 0xD6.chr + "!\n") }
-      exception = assert_raises(RuntimeError) do
+      exception = assert_raises(Nanoc::DataSources::Filesystem::Errors::InvalidEncoding) do
         Nanoc::Int::SiteLoader.new.new_from_cwd
       end
       assert_equal 'Could not read content/index.html because the file is not valid UTF-8.', exception.message
diff --git a/test/cli/commands/test_help.rb b/test/cli/commands/test_help.rb
index 69548b0..8757bc2 100644
--- a/test/cli/commands/test_help.rb
+++ b/test/cli/commands/test_help.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::Commands::HelpTest < Nanoc::TestCase
   def test_run
     Nanoc::CLI.run %w(help)
diff --git a/test/cli/commands/test_info.rb b/test/cli/commands/test_info.rb
index 9d293b7..e639db1 100644
--- a/test/cli/commands/test_info.rb
+++ b/test/cli/commands/test_info.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::Commands::InfoTest < Nanoc::TestCase
   def test_run
     Nanoc::CLI.run %w(info)
diff --git a/test/cli/commands/test_prune.rb b/test/cli/commands/test_prune.rb
index e2ed950..e598d96 100644
--- a/test/cli/commands/test_prune.rb
+++ b/test/cli/commands/test_prune.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::Commands::PruneTest < Nanoc::TestCase
   def test_run_without_yes
     with_site do |_site|
diff --git a/test/cli/test_cleaning_stream.rb b/test/cli/test_cleaning_stream.rb
index 4a1faea..7cd1587 100644
--- a/test/cli/test_cleaning_stream.rb
+++ b/test/cli/test_cleaning_stream.rb
@@ -1,9 +1,11 @@
+require 'helper'
+
 class Nanoc::CLI::CleaningStreamTest < Nanoc::TestCase
   class Stream
     attr_accessor :called_methods
 
     def initialize
-      @called_methods = Set.new
+      @called_methods = []
     end
 
     # rubocop:disable Style/MethodMissing
@@ -18,7 +20,7 @@ class Nanoc::CLI::CleaningStreamTest < Nanoc::TestCase
   end
 
   def test_forward
-    methods = [:write, :<<, :tty?, :flush, :tell, :print, :puts, :string, :reopen, :exist?, :exists?, :close]
+    methods = [:write, :<<, :tty?, :tty?, :flush, :tell, :print, :puts, :string, :reopen, :exist?, :exists?, :close]
 
     s = Stream.new
     cs = Nanoc::CLI::CleaningStream.new(s)
@@ -26,6 +28,7 @@ class Nanoc::CLI::CleaningStreamTest < Nanoc::TestCase
     cs.write('aaa')
     cs << 'bb'
     cs.tty?
+    cs.isatty
     cs.flush
     cs.tell
     cs.print('cc')
@@ -41,6 +44,16 @@ class Nanoc::CLI::CleaningStreamTest < Nanoc::TestCase
     end
   end
 
+  def test_forward_tty_cached
+    s = Stream.new
+    cs = Nanoc::CLI::CleaningStream.new(s)
+
+    cs.tty?
+    cs.isatty
+
+    assert_equal [:tty?], s.called_methods
+  end
+
   def test_works_with_logger
     require 'logger'
     stream = StringIO.new
diff --git a/test/cli/test_cli.rb b/test/cli/test_cli.rb
index 32d53d6..3c7340c 100644
--- a/test/cli/test_cli.rb
+++ b/test/cli/test_cli.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLITest < Nanoc::TestCase
   COMMAND_CODE = <<EOS.freeze
 usage       '_test [options]'
@@ -176,17 +178,17 @@ EOS
     with_env_vars(new_env_diff) do
       refute Nanoc::CLI.enable_utf8?(io)
 
-      with_env_vars({ 'LC_ALL'   => 'en_US.UTF-8' }) { assert Nanoc::CLI.enable_utf8?(io) }
-      with_env_vars({ 'LC_CTYPE' => 'en_US.UTF-8' }) { assert Nanoc::CLI.enable_utf8?(io) }
-      with_env_vars({ 'LANG'     => 'en_US.UTF-8' }) { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LC_ALL'   => 'en_US.UTF-8') { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LC_CTYPE' => 'en_US.UTF-8') { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LANG'     => 'en_US.UTF-8') { assert Nanoc::CLI.enable_utf8?(io) }
 
-      with_env_vars({ 'LC_ALL'   => 'en_US.utf-8' }) { assert Nanoc::CLI.enable_utf8?(io) }
-      with_env_vars({ 'LC_CTYPE' => 'en_US.utf-8' }) { assert Nanoc::CLI.enable_utf8?(io) }
-      with_env_vars({ 'LANG'     => 'en_US.utf-8' }) { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LC_ALL'   => 'en_US.utf-8') { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LC_CTYPE' => 'en_US.utf-8') { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LANG'     => 'en_US.utf-8') { assert Nanoc::CLI.enable_utf8?(io) }
 
-      with_env_vars({ 'LC_ALL'   => 'en_US.utf8'  }) { assert Nanoc::CLI.enable_utf8?(io) }
-      with_env_vars({ 'LC_CTYPE' => 'en_US.utf8'  }) { assert Nanoc::CLI.enable_utf8?(io) }
-      with_env_vars({ 'LANG'     => 'en_US.utf8'  }) { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LC_ALL'   => 'en_US.utf8') { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LC_CTYPE' => 'en_US.utf8') { assert Nanoc::CLI.enable_utf8?(io) }
+      with_env_vars('LANG'     => 'en_US.utf8') { assert Nanoc::CLI.enable_utf8?(io) }
     end
   end
 end
diff --git a/test/cli/test_error_handler.rb b/test/cli/test_error_handler.rb
index 304e4c9..5dec48a 100644
--- a/test/cli/test_error_handler.rb
+++ b/test/cli/test_error_handler.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::ErrorHandlerTest < Nanoc::TestCase
   def setup
     super
@@ -46,7 +48,42 @@ class Nanoc::CLI::ErrorHandlerTest < Nanoc::TestCase
     refute_match(/See full crash log for details./, stream.string)
   end
 
-  def new_error(amount_factor)
+  def test_write_error_message_wrapped
+    stream = StringIO.new
+    @handler.send(:write_error_message, stream, new_wrapped_error(new_error), verbose: true)
+    refute_match(/CompilationError/, stream.string)
+  end
+
+  def test_write_stack_trace_wrapped
+    stream = StringIO.new
+    @handler.send(:write_stack_trace, stream, new_wrapped_error(new_error), verbose: false)
+    assert_match(/new_error/, stream.string)
+  end
+
+  def test_write_item_rep
+    stream = StringIO.new
+    @handler.send(:write_item_rep, stream, new_wrapped_error(new_error), verbose: false)
+    assert_match(/^Item identifier: \/about\.md$/, stream.string)
+    assert_match(/^Item rep name:   :latex$/, stream.string)
+  end
+
+  def test_resolution_for_wrapped
+    def @handler.using_bundler?
+      true
+    end
+    error = new_wrapped_error(LoadError.new('no such file to load -- kramdown'))
+    assert_match(/^Make sure the gem is added to Gemfile/, @handler.send(:resolution_for, error))
+  end
+
+  def new_wrapped_error(wrapped)
+    item = Nanoc::Int::Item.new('asdf', {}, '/about.md')
+    item_rep = Nanoc::Int::ItemRep.new(item, :latex)
+    raise Nanoc::Int::Errors::CompilationError.new(wrapped, item_rep)
+  rescue => e
+    return e
+  end
+
+  def new_error(amount_factor = 1)
     backtrace_generator = lambda do |af|
       if af.zero?
         raise 'finally!'
diff --git a/test/cli/test_logger.rb b/test/cli/test_logger.rb
index 4a9e5a6..94a029c 100644
--- a/test/cli/test_logger.rb
+++ b/test/cli/test_logger.rb
@@ -1,4 +1,5 @@
+require 'helper'
+
 class Nanoc::CLI::LoggerTest < Nanoc::TestCase
-  def test_stub
-  end
+  def test_stub; end
 end
diff --git a/test/data_sources/test_filesystem.rb b/test/data_sources/test_filesystem.rb
index 72b92f3..595e888 100644
--- a/test/data_sources/test_filesystem.rb
+++ b/test/data_sources/test_filesystem.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
   def new_data_source(params = nil)
     # Mock site
@@ -69,7 +71,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
 
   def test_load_objects_with_same_extensions
     # Create data source
-    data_source = new_data_source({ identifier_type: 'full' })
+    data_source = new_data_source(identifier_type: 'full')
 
     # Create a fake class
     klass = Class.new do
@@ -135,14 +137,14 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
     File.open('foo/stuff.dat', 'w') { |io| io.write('random binary data') }
 
     # Load
-    assert_raises(RuntimeError) do
+    assert_raises(Nanoc::DataSources::Filesystem::Errors::BinaryLayout) do
       data_source.send(:load_objects, 'foo', Nanoc::Int::Layout)
     end
   end
 
   def test_identifier_for_filename_with_full_style_identifier
     # Create data source
-    data_source = new_data_source({ identifier_type: 'full' })
+    data_source = new_data_source(identifier_type: 'full')
 
     # Get input and expected output
     expected = {
@@ -463,7 +465,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
   end
 
   def test_load_objects_correct_identifier_with_separate_yaml_file
-    data_source = new_data_source({ identifier_type: 'full' })
+    data_source = new_data_source(identifier_type: 'full')
 
     FileUtils.mkdir_p('foo')
     File.write('foo/donkey.jpeg', 'data')
@@ -483,24 +485,6 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
     assert_equal nil,            data_source.send(:filename_for, '/foo', nil)
   end
 
-  def test_compile_huge_site
-    if_implemented do
-      # Create data source
-      data_source = new_data_source
-
-      # Create a lot of items
-      count = Process.getrlimit(Process::RLIMIT_NOFILE)[0] + 5
-      count.times do |i|
-        FileUtils.mkdir_p("content/#{i}")
-        File.open("content/#{i}/#{i}.html", 'w') { |io| io << "This is item #{i}." }
-        File.open("content/#{i}/#{i}.yaml", 'w') { |io| io << "title: Item #{i}"   }
-      end
-
-      # Read all items
-      data_source.items
-    end
-  end
-
   def test_compile_iso_8859_1_site
     # Check encoding
     unless ''.respond_to?(:encode)
@@ -559,7 +543,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
 
   def test_all_split_files_in_allowing_periods_in_identifiers
     # Create data source
-    data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, { allow_periods_in_identifiers: true })
+    data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, allow_periods_in_identifiers: true)
 
     # Write sample files
     FileUtils.mkdir_p('foo')
@@ -660,14 +644,14 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
     end
 
     # Check
-    assert_raises RuntimeError do
+    assert_raises(Nanoc::DataSources::Filesystem::Errors::MultipleContentFiles) do
       data_source.send(:all_split_files_in, '.')
     end
   end
 
   def test_basename_of_allowing_periods_in_identifiers
     # Create data source
-    data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, { allow_periods_in_identifiers: true })
+    data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, allow_periods_in_identifiers: true)
 
     # Get input and expected output
     expected = {
@@ -727,7 +711,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
 
   def test_ext_of_allowing_periods_in_identifiers
     # Create data source
-    data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, { allow_periods_in_identifiers: true })
+    data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, allow_periods_in_identifiers: true)
 
     # Get input and expected output
     expected = {
@@ -847,7 +831,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
     data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, nil)
 
     # Parse it
-    assert_raises(RuntimeError) do
+    assert_raises(Nanoc::DataSources::Filesystem::Errors::InvalidFormat) do
       data_source.instance_eval { parse('test.html', nil) }
     end
   end
@@ -999,7 +983,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
 
     data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, nil)
 
-    assert_raises(Nanoc::DataSources::Filesystem::InvalidMetadataError) do
+    assert_raises(Nanoc::DataSources::Filesystem::Errors::InvalidMetadata) do
       data_source.instance_eval { parse('test.html', nil) }
     end
   end
@@ -1010,7 +994,7 @@ class Nanoc::DataSources::FilesystemTest < Nanoc::TestCase
 
     data_source = Nanoc::DataSources::Filesystem.new(nil, nil, nil, nil)
 
-    assert_raises(Nanoc::DataSources::Filesystem::InvalidMetadataError) do
+    assert_raises(Nanoc::DataSources::Filesystem::Errors::InvalidMetadata) do
       data_source.instance_eval { parse('test.html', 'test.yaml') }
     end
   end
diff --git a/test/extra/test_filesystem_tools.rb b/test/data_sources/test_filesystem_tools.rb
similarity index 76%
rename from test/extra/test_filesystem_tools.rb
rename to test/data_sources/test_filesystem_tools.rb
index bcef74f..138ab53 100644
--- a/test/extra/test_filesystem_tools.rb
+++ b/test/data_sources/test_filesystem_tools.rb
@@ -1,4 +1,6 @@
-class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::DataSources::FilesystemToolsTest < Nanoc::TestCase
   def setup
     super
     skip_unless_symlinks_supported
@@ -30,7 +32,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
       'dir0/sub/sub/sub/sub/sub/sub/sub/sub/sub/foo.md',
       'dir0/sub/sub/sub/sub/sub/sub/sub/sub/sub/sub/foo.md',
     ]
-    actual_files = Nanoc::Extra::FilesystemTools.all_files_in('dir0', nil).sort
+    actual_files = Nanoc::DataSources::Filesystem::Tools.all_files_in('dir0', nil).sort
     assert_equal expected_files, actual_files
   end
 
@@ -44,8 +46,8 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
       File.symlink("../dir#{i}", "dir#{i - 1}/sub")
     end
 
-    assert_raises Nanoc::Extra::FilesystemTools::MaxSymlinkDepthExceededError do
-      Nanoc::Extra::FilesystemTools.all_files_in('dir0', nil)
+    assert_raises Nanoc::DataSources::Filesystem::Tools::MaxSymlinkDepthExceededError do
+      Nanoc::DataSources::Filesystem::Tools.all_files_in('dir0', nil)
     end
   end
 
@@ -59,7 +61,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
     File.symlink('../bar', 'foo/barlink')
 
     expected_files = ['foo/barlink/y.md', 'foo/x.md']
-    actual_files   = Nanoc::Extra::FilesystemTools.all_files_in('foo', nil).sort
+    actual_files   = Nanoc::DataSources::Filesystem::Tools.all_files_in('foo', nil).sort
     assert_equal expected_files, actual_files
   end
 
@@ -72,7 +74,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
 
     # Check
     expected_files = ['dir/bar-link', 'dir/foo']
-    actual_files   = Nanoc::Extra::FilesystemTools.all_files_in('dir', nil).sort
+    actual_files   = Nanoc::DataSources::Filesystem::Tools.all_files_in('dir', nil).sort
     assert_equal expected_files, actual_files
   end
 
@@ -83,7 +85,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
     File.symlink('baz', 'qux')
 
     expected = File.expand_path('foo')
-    actual   = Nanoc::Extra::FilesystemTools.resolve_symlink('qux')
+    actual   = Nanoc::DataSources::Filesystem::Tools.resolve_symlink('qux')
     assert_equal expected, actual
   end
 
@@ -94,8 +96,8 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
       File.symlink("symlink-#{i - 1}", "symlink-#{i}")
     end
 
-    assert_raises Nanoc::Extra::FilesystemTools::MaxSymlinkDepthExceededError do
-      Nanoc::Extra::FilesystemTools.resolve_symlink('symlink-7')
+    assert_raises Nanoc::DataSources::Filesystem::Tools::MaxSymlinkDepthExceededError do
+      Nanoc::DataSources::Filesystem::Tools.resolve_symlink('symlink-7')
     end
   end
 
@@ -105,7 +107,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
     File.open('dir/.DS_Store', 'w') { |io| io.write('o hai') }
     File.open('dir/.htaccess', 'w') { |io| io.write('o hai') }
 
-    actual_files = Nanoc::Extra::FilesystemTools.all_files_in('dir', nil).sort
+    actual_files = Nanoc::DataSources::Filesystem::Tools.all_files_in('dir', nil).sort
     assert_equal [], actual_files
   end
 
@@ -114,7 +116,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
     FileUtils.mkdir_p('dir')
     File.open('dir/.other', 'w') { |io| io.write('o hai') }
 
-    actual_files = Nanoc::Extra::FilesystemTools.all_files_in('dir', '**/.other').sort
+    actual_files = Nanoc::DataSources::Filesystem::Tools.all_files_in('dir', '**/.other').sort
     assert_equal ['dir/.other'], actual_files
   end
 
@@ -124,7 +126,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
     File.open('dir/.other', 'w') { |io| io.write('o hai') }
     File.open('dir/.DS_Store', 'w') { |io| io.write('o hai') }
 
-    actual_files = Nanoc::Extra::FilesystemTools.all_files_in('dir', ['**/.other', '**/.DS_Store']).sort
+    actual_files = Nanoc::DataSources::Filesystem::Tools.all_files_in('dir', ['**/.other', '**/.DS_Store']).sort
     assert_equal ['dir/.other', 'dir/.DS_Store'].sort, actual_files.sort
   end
 
@@ -136,7 +138,7 @@ class Nanoc::Extra::FilesystemToolsTest < Nanoc::TestCase
     pattern = { dotfiles: '**/.other' }
 
     assert_raises Nanoc::Int::Errors::GenericTrivial, "Do not know how to handle extra_files: #{pattern.inspect}" do
-      Nanoc::Extra::FilesystemTools.all_files_in('dir0', pattern)
+      Nanoc::DataSources::Filesystem::Tools.all_files_in('dir0', pattern)
     end
   end
 end
diff --git a/test/extra/deployers/test_fog.rb b/test/deploying/test_fog.rb
similarity index 83%
rename from test/extra/deployers/test_fog.rb
rename to test/deploying/test_fog.rb
index 0723e69..5a2b4be 100644
--- a/test/extra/deployers/test_fog.rb
+++ b/test/deploying/test_fog.rb
@@ -1,7 +1,9 @@
-class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Deploying::Deployers::FogTest < Nanoc::TestCase
   def test_read_etags_with_local_provider
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'local'
       )
 
@@ -16,7 +18,7 @@ class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
 
   def test_read_etags_with_aws_provider
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'aws'
       )
 
@@ -36,7 +38,7 @@ class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
 
   def test_calc_local_etag_with_local_provider
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'local'
       )
 
@@ -49,7 +51,7 @@ class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
 
   def test_calc_local_etag_with_aws_provider
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'aws'
       )
 
@@ -65,7 +67,7 @@ class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
 
   def test_needs_upload_with_missing_remote_etag
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'aws'
       )
 
@@ -81,7 +83,7 @@ class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
 
   def test_needs_upload_with_different_etags
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'aws'
       )
 
@@ -97,7 +99,7 @@ class Nanoc::Extra::Deployers::FogTest < Nanoc::TestCase
 
   def test_needs_upload_with_identical_etags
     if_have 'fog' do
-      fog = Nanoc::Extra::Deployers::Fog.new(
+      fog = Nanoc::Deploying::Deployers::Fog.new(
         'output/', provider: 'aws'
       )
 
diff --git a/test/extra/deployers/test_rsync.rb b/test/deploying/test_rsync.rb
similarity index 76%
rename from test/extra/deployers/test_rsync.rb
rename to test/deploying/test_rsync.rb
index 0ea05ce..6cf0529 100644
--- a/test/extra/deployers/test_rsync.rb
+++ b/test/deploying/test_rsync.rb
@@ -1,7 +1,9 @@
-class Nanoc::Extra::Deployers::RsyncTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Deploying::Deployers::RsyncTest < Nanoc::TestCase
   def test_run_without_dst
     # Create deployer
-    rsync = Nanoc::Extra::Deployers::Rsync.new(
+    rsync = Nanoc::Deploying::Deployers::Rsync.new(
       'output/',
       {},
     )
@@ -22,9 +24,9 @@ class Nanoc::Extra::Deployers::RsyncTest < Nanoc::TestCase
 
   def test_run_with_erroneous_dst
     # Create deployer
-    rsync = Nanoc::Extra::Deployers::Rsync.new(
+    rsync = Nanoc::Deploying::Deployers::Rsync.new(
       'output/',
-      { dst: 'asdf/' },
+      dst: 'asdf/',
     )
 
     # Mock run_shell_cmd
@@ -43,9 +45,9 @@ class Nanoc::Extra::Deployers::RsyncTest < Nanoc::TestCase
 
   def test_run_everything_okay
     # Create deployer
-    rsync = Nanoc::Extra::Deployers::Rsync.new(
+    rsync = Nanoc::Deploying::Deployers::Rsync.new(
       'output',
-      { dst: 'asdf' },
+      dst: 'asdf',
     )
 
     # Mock run_shell_cmd
@@ -57,7 +59,7 @@ class Nanoc::Extra::Deployers::RsyncTest < Nanoc::TestCase
     rsync.run
 
     # Check args
-    opts = Nanoc::Extra::Deployers::Rsync::DEFAULT_OPTIONS
+    opts = Nanoc::Deploying::Deployers::Rsync::DEFAULT_OPTIONS
     assert_equal(
       ['rsync', opts, 'output/', 'asdf'].flatten,
       rsync.instance_eval { @shell_cms_args },
@@ -66,7 +68,7 @@ class Nanoc::Extra::Deployers::RsyncTest < Nanoc::TestCase
 
   def test_run_everything_okay_dry
     # Create deployer
-    rsync = Nanoc::Extra::Deployers::Rsync.new(
+    rsync = Nanoc::Deploying::Deployers::Rsync.new(
       'output',
       { dst: 'asdf' },
       dry_run: true,
@@ -81,7 +83,7 @@ class Nanoc::Extra::Deployers::RsyncTest < Nanoc::TestCase
     rsync.run
 
     # Check args
-    opts = Nanoc::Extra::Deployers::Rsync::DEFAULT_OPTIONS
+    opts = Nanoc::Deploying::Deployers::Rsync::DEFAULT_OPTIONS
     assert_equal(
       ['echo', 'rsync', opts, 'output/', 'asdf'].flatten,
       rsync.instance_eval { @shell_cms_args },
diff --git a/test/extra/core_ext/test_pathname.rb b/test/extra/core_ext/test_pathname.rb
index 544277f..4d2b537 100644
--- a/test/extra/core_ext/test_pathname.rb
+++ b/test/extra/core_ext/test_pathname.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Extra::CoreExtPathnameTest < Nanoc::TestCase
   def test_components
     assert_equal %w(/ a bb ccc dd e), Pathname.new('/a/bb/ccc/dd/e').__nanoc_components
diff --git a/test/extra/core_ext/test_time.rb b/test/extra/core_ext/test_time.rb
index 5733f5f..735d71c 100644
--- a/test/extra/core_ext/test_time.rb
+++ b/test/extra/core_ext/test_time.rb
@@ -1,8 +1,14 @@
+require 'helper'
+
 class Nanoc::ExtraCoreExtTimeTest < Nanoc::TestCase
-  def test___nanoc_to_iso8601_date
+  def test___nanoc_to_iso8601_date_utc
     assert_equal('2008-05-19', Time.utc(2008, 5, 19, 14, 20, 0, 0).__nanoc_to_iso8601_date)
   end
 
+  def test___nanoc_to_iso8601_date_non_utc
+    assert_equal('2008-05-18', Time.new(2008, 5, 19, 0, 0, 0, '+02:00').__nanoc_to_iso8601_date)
+  end
+
   def test___nanoc_to_iso8601_time
     assert_equal('2008-05-19T14:20:00Z', Time.utc(2008, 5, 19, 14, 20, 0, 0).__nanoc_to_iso8601_time)
   end
diff --git a/test/extra/test_link_collector.rb b/test/extra/test_link_collector.rb
index 5dfe33c..668d399 100644
--- a/test/extra/test_link_collector.rb
+++ b/test/extra/test_link_collector.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Extra::LinkCollectorTest < Nanoc::TestCase
   def test_all
     # Create dummy data
diff --git a/test/extra/test_piper.rb b/test/extra/test_piper.rb
index 60ccd0c..a8a5f39 100644
--- a/test/extra/test_piper.rb
+++ b/test/extra/test_piper.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Extra::PiperTest < Nanoc::TestCase
   def test_basic
     stdout = StringIO.new
diff --git a/test/filters/test_colorize_syntax.rb b/test/filters/colorize_syntax/test_coderay.rb
similarity index 56%
rename from test/filters/test_colorize_syntax.rb
rename to test/filters/colorize_syntax/test_coderay.rb
index d19f040..3bbf580 100644
--- a/test/filters/test_colorize_syntax.rb
+++ b/test/filters/colorize_syntax/test_coderay.rb
@@ -1,4 +1,6 @@
-class Nanoc::Filters::ColorizeSyntaxTest < Nanoc::TestCase
+require 'helper'
+
+class Nanoc::Filters::ColorizeSyntax::CoderayTest < Nanoc::TestCase
   CODERAY_PRE  = '<div class="CodeRay"><div class="code">'.freeze
   CODERAY_POST = '</div></div>'.freeze
 
@@ -17,56 +19,6 @@ class Nanoc::Filters::ColorizeSyntaxTest < Nanoc::TestCase
     end
   end
 
-  def test_dummy
-    if_have 'nokogiri' do
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = '<pre title="moo"><code class="language-ruby"># comment</code></pre>'
-      expected_output = input # because we are using a dummy
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, default_colorizer: :dummy)
-      assert_equal(expected_output, actual_output)
-    end
-  end
-
-  def test_with_frozen_input
-    if_have 'nokogiri' do
-      input = '<pre title="moo"><code class="language-ruby"># comment</code></pre>'.freeze
-      input.freeze
-
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-      filter.setup_and_run(input, default_colorizer: :dummy)
-    end
-  end
-
-  def test_full_page
-    if_have 'nokogiri' do
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = <<EOS
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>Foo</title>
-  </head>
-  <body>
-    <pre title="moo"><code class="language-ruby"># comment</code></pre>
-  </body>
-</html>
-EOS
-      expected_output_regex = %r{^<!DOCTYPE html>\s*<html>\s*<head>\s*<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\s*<title>Foo</title>\s*</head>\s*<body>\s*<pre title="moo"><code class="language-ruby"># comment</code></pre>\s*</body>\s*</html>}
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, default_colorizer: :dummy, is_fullpage: true)
-      assert_match expected_output_regex, actual_output
-    end
-  end
-
   def test_coderay_with_comment
     if_have 'coderay', 'nokogiri' do
       # Create filter
@@ -132,58 +84,6 @@ EOS
     end
   end
 
-  def test_pygmentize
-    if_have 'nokogiri' do
-      skip_unless_have_command 'pygmentize'
-
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = '<pre title="moo"><code class="language-ruby"># comment</code></pre>'
-      expected_output = '<pre title="moo"><code class="language-ruby"><span class="c1"># comment</span></code></pre>'
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, colorizers: { ruby: :pygmentize })
-      assert_equal(expected_output, actual_output)
-    end
-  end
-
-  def test_pygmentsrb
-    skip 'pygments.rb does not support Windows' if on_windows?
-    if_have 'pygments', 'nokogiri' do
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = '<pre title="moo"><code class="language-ruby"># comment…</code></pre>'
-      expected_output = '<pre title="moo"><code class="language-ruby"><span class="c1"># comment…</span></code></pre>'
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, colorizers: { ruby: :pygmentsrb })
-      assert_equal(expected_output, actual_output)
-    end
-  end
-
-  def test_simon_highlight
-    if_have 'nokogiri' do
-      skip_unless_have_command 'highlight'
-
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = %(<pre title="moo"><code class="language-ruby">
-# comment
-</code></pre>)
-      expected_output = '<pre title="moo"><code class="language-ruby"><span class="hl slc"># comment</span></code></pre>'
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, default_colorizer: :simon_highlight)
-      assert_equal(expected_output, actual_output)
-    end
-  end
-
   def test_colorize_syntax_with_unknown_syntax
     if_have 'coderay', 'nokogiri' do
       # Create filter
@@ -226,53 +126,6 @@ EOS
     end
   end
 
-  def test_colorize_syntax_with_default_colorizer
-    skip_unless_have_command 'pygmentize'
-
-    if_have 'nokogiri' do
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = '<pre><code class="language-ruby">puts "foo"</code></pre>'
-      expected_output = '<pre><code class="language-ruby"><span class="nb">puts</span> <span class="s2">"foo"</span></code></pre>'
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, default_colorizer: :pygmentize)
-      assert_equal(expected_output, actual_output)
-    end
-  end
-
-  def test_colorize_syntax_with_missing_executables
-    if_have 'nokogiri' do
-      begin
-        original_path = ENV['PATH']
-        ENV['PATH'] = './blooblooblah'
-
-        # Create filter
-        filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-        # Get input and expected output
-        input = '<pre><code class="language-ruby">puts "foo"</code></pre>'
-
-        # Run filter
-        [:albino, :pygmentize, :simon_highlight].each do |colorizer|
-          begin
-            input = '<pre><code class="language-ruby">puts "foo"</code></pre>'
-            filter.setup_and_run(
-              input,
-              colorizers: { ruby: colorizer },
-            )
-            flunk 'expected colorizer to raise if no executable is available'
-          rescue
-          end
-        end
-      ensure
-        ENV['PATH'] = original_path
-      end
-    end
-  end
-
   def test_colorize_syntax_with_non_language_shebang_line
     if_have 'coderay', 'nokogiri' do
       # Create filter
@@ -389,31 +242,4 @@ EOS
       assert_equal(expected_output, actual_output)
     end
   end
-
-  def test_rouge
-    if_have 'rouge', 'nokogiri' do
-      # Create filter
-      filter = ::Nanoc::Filters::ColorizeSyntax.new
-
-      # Get input and expected output
-      input = <<EOS
-before
-<pre><code class="language-ruby">
-  def foo
-  end
-</code></pre>
-after
-EOS
-      expected_output = <<EOS
-before
-<pre><code class=\"language-ruby\">  <span class=\"k\">def</span> <span class=\"nf\">foo</span>
-  <span class=\"k\">end</span></code></pre>
-after
-EOS
-
-      # Run filter
-      actual_output = filter.setup_and_run(input, default_colorizer: :rouge)
-      assert_equal(expected_output, actual_output)
-    end
-  end
 end
diff --git a/test/filters/colorize_syntax/test_common.rb b/test/filters/colorize_syntax/test_common.rb
new file mode 100644
index 0000000..1f0ce14
--- /dev/null
+++ b/test/filters/colorize_syntax/test_common.rb
@@ -0,0 +1,83 @@
+require 'helper'
+
+class Nanoc::Filters::ColorizeSyntax::CommonTest < Nanoc::TestCase
+  def test_dummy
+    if_have 'nokogiri' do
+      # Create filter
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+      # Get input and expected output
+      input = '<pre title="moo"><code class="language-ruby"># comment</code></pre>'
+      expected_output = input # because we are using a dummy
+
+      # Run filter
+      actual_output = filter.setup_and_run(input, default_colorizer: :dummy)
+      assert_equal(expected_output, actual_output)
+    end
+  end
+
+  def test_with_frozen_input
+    if_have 'nokogiri' do
+      input = '<pre title="moo"><code class="language-ruby"># comment</code></pre>'.freeze
+      input.freeze
+
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+      filter.setup_and_run(input, default_colorizer: :dummy)
+    end
+  end
+
+  def test_full_page
+    if_have 'nokogiri' do
+      # Create filter
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+      # Get input and expected output
+      input = <<EOS
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Foo</title>
+  </head>
+  <body>
+    <pre title="moo"><code class="language-ruby"># comment</code></pre>
+  </body>
+</html>
+EOS
+      expected_output_regex = %r{^<!DOCTYPE html>\s*<html>\s*<head>\s*<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\s*<title>Foo</title>\s*</head>\s*<body>\s*<pre title="moo"><code class="language-ruby"># comment</code></pre>\s*</body>\s*</html>}
+
+      # Run filter
+      actual_output = filter.setup_and_run(input, default_colorizer: :dummy, is_fullpage: true)
+      assert_match expected_output_regex, actual_output
+    end
+  end
+
+  def test_colorize_syntax_with_missing_executables
+    if_have 'nokogiri' do
+      begin
+        original_path = ENV['PATH']
+        ENV['PATH'] = './blooblooblah'
+
+        # Create filter
+        filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+        # Get input and expected output
+        input = '<pre><code class="language-ruby">puts "foo"</code></pre>'
+
+        # Run filter
+        [:albino, :pygmentize, :simon_highlight].each do |colorizer|
+          begin
+            input = '<pre><code class="language-ruby">puts "foo"</code></pre>'
+            filter.setup_and_run(
+              input,
+              colorizers: { ruby: colorizer },
+            )
+            flunk 'expected colorizer to raise if no executable is available'
+          rescue
+          end
+        end
+      ensure
+        ENV['PATH'] = original_path
+      end
+    end
+  end
+end
diff --git a/test/filters/colorize_syntax/test_pygmentize.rb b/test/filters/colorize_syntax/test_pygmentize.rb
new file mode 100644
index 0000000..cc28b32
--- /dev/null
+++ b/test/filters/colorize_syntax/test_pygmentize.rb
@@ -0,0 +1,37 @@
+require 'helper'
+
+class Nanoc::Filters::ColorizeSyntax::PygmentizeTest < Nanoc::TestCase
+  def test_pygmentize
+    if_have 'nokogiri' do
+      skip_unless_have_command 'pygmentize'
+
+      # Create filter
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+      # Get input and expected output
+      input = '<pre title="moo"><code class="language-ruby"># comment</code></pre>'
+      expected_output = '<pre title="moo"><code class="language-ruby"><span class="c1"># comment</span></code></pre>'
+
+      # Run filter
+      actual_output = filter.setup_and_run(input, colorizers: { ruby: :pygmentize })
+      assert_equal(expected_output, actual_output)
+    end
+  end
+
+  def test_colorize_syntax_with_default_colorizer
+    skip_unless_have_command 'pygmentize'
+
+    if_have 'nokogiri' do
+      # Create filter
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+      # Get input and expected output
+      input = '<pre><code class="language-ruby">puts "foo"</code></pre>'
+      expected_output = '<pre><code class="language-ruby"><span class="nb">puts</span> <span class="s2">"foo"</span></code></pre>'
+
+      # Run filter
+      actual_output = filter.setup_and_run(input, default_colorizer: :pygmentize)
+      assert_equal(expected_output, actual_output)
+    end
+  end
+end
diff --git a/test/filters/colorize_syntax/test_pygments.rb b/test/filters/colorize_syntax/test_pygments.rb
new file mode 100644
index 0000000..0f5d9f5
--- /dev/null
+++ b/test/filters/colorize_syntax/test_pygments.rb
@@ -0,0 +1,19 @@
+require 'helper'
+
+class Nanoc::Filters::ColorizeSyntax::PygmentsTest < Nanoc::TestCase
+  def test_pygmentsrb
+    skip 'pygments.rb does not support Windows' if on_windows?
+    if_have 'pygments', 'nokogiri' do
+      # Create filter
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+      # Get input and expected output
+      input = '<pre title="moo"><code class="language-ruby"># comment…</code></pre>'
+      expected_output = '<pre title="moo"><code class="language-ruby"><span class="c1"># comment…</span></code></pre>'
+
+      # Run filter
+      actual_output = filter.setup_and_run(input, colorizers: { ruby: :pygmentsrb })
+      assert_equal(expected_output, actual_output)
+    end
+  end
+end
diff --git a/test/filters/colorize_syntax/test_simon.rb b/test/filters/colorize_syntax/test_simon.rb
new file mode 100644
index 0000000..a9ad0ba
--- /dev/null
+++ b/test/filters/colorize_syntax/test_simon.rb
@@ -0,0 +1,22 @@
+require 'helper'
+
+class Nanoc::Filters::ColorizeSyntax::SimonTest < Nanoc::TestCase
+  def test_simon_highlight
+    if_have 'nokogiri' do
+      skip_unless_have_command 'highlight'
+
+      # Create filter
+      filter = ::Nanoc::Filters::ColorizeSyntax.new
+
+      # Get input and expected output
+      input = %(<pre title="moo"><code class="language-ruby">
+# comment
+</code></pre>)
+      expected_output = '<pre title="moo"><code class="language-ruby"><span class="hl slc"># comment</span></code></pre>'
+
+      # Run filter
+      actual_output = filter.setup_and_run(input, default_colorizer: :simon_highlight)
+      assert_equal(expected_output, actual_output)
+    end
+  end
+end
diff --git a/test/filters/test_asciidoc.rb b/test/filters/test_asciidoc.rb
index 012fe68..1b1859c 100644
--- a/test/filters/test_asciidoc.rb
+++ b/test/filters/test_asciidoc.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::AsciiDocTest < Nanoc::TestCase
   def test_filter
     skip_unless_have_command 'asciidoc'
diff --git a/test/filters/test_bluecloth.rb b/test/filters/test_bluecloth.rb
index 7c804b6..e81fa0f 100644
--- a/test/filters/test_bluecloth.rb
+++ b/test/filters/test_bluecloth.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::BlueClothTest < Nanoc::TestCase
   def test_filter
     if_have 'bluecloth' do
diff --git a/test/filters/test_coffeescript.rb b/test/filters/test_coffeescript.rb
index bf46970..12c982c 100644
--- a/test/filters/test_coffeescript.rb
+++ b/test/filters/test_coffeescript.rb
@@ -1,5 +1,9 @@
+require 'helper'
+
 class Nanoc::Filters::CoffeeScriptTest < Nanoc::TestCase
   def test_filter
+    skip_v8_on_ruby24
+
     if_have 'coffee-script' do
       # Create filter
       filter = ::Nanoc::Filters::CoffeeScript.new
diff --git a/test/filters/test_erb.rb b/test/filters/test_erb.rb
index 1d4bb05..2405650 100644
--- a/test/filters/test_erb.rb
+++ b/test/filters/test_erb.rb
@@ -1,7 +1,9 @@
+require 'helper'
+
 class Nanoc::Filters::ERBTest < Nanoc::TestCase
   def test_filter_with_instance_variable
     # Create filter
-    filter = ::Nanoc::Filters::ERB.new({ location: 'a cheap motel' })
+    filter = ::Nanoc::Filters::ERB.new(location: 'a cheap motel')
 
     # Run filter
     result = filter.setup_and_run('<%= "I was hiding in #{@location}." %>')
@@ -10,7 +12,7 @@ class Nanoc::Filters::ERBTest < Nanoc::TestCase
 
   def test_filter_with_instance_method
     # Create filter
-    filter = ::Nanoc::Filters::ERB.new({ location: 'a cheap motel' })
+    filter = ::Nanoc::Filters::ERB.new(location: 'a cheap motel')
 
     # Run filter
     result = filter.setup_and_run('<%= "I was hiding in #{location}." %>')
@@ -45,7 +47,7 @@ class Nanoc::Filters::ERBTest < Nanoc::TestCase
 
   def test_filter_with_yield
     # Create filter
-    filter = ::Nanoc::Filters::ERB.new({ content: 'a cheap motel' })
+    filter = ::Nanoc::Filters::ERB.new(content: 'a cheap motel')
 
     # Run filter
     result = filter.setup_and_run('<%= "I was hiding in #{yield}." %>')
@@ -54,7 +56,7 @@ class Nanoc::Filters::ERBTest < Nanoc::TestCase
 
   def test_filter_with_yield_without_content
     # Create filter
-    filter = ::Nanoc::Filters::ERB.new({ location: 'a cheap motel' })
+    filter = ::Nanoc::Filters::ERB.new(location: 'a cheap motel')
 
     # Run filter
     assert_raises LocalJumpError do
@@ -83,7 +85,7 @@ class Nanoc::Filters::ERBTest < Nanoc::TestCase
 
   def test_trim_mode
     # Set up
-    filter = ::Nanoc::Filters::ERB.new({ location: 'a cheap motel' })
+    filter = ::Nanoc::Filters::ERB.new(location: 'a cheap motel')
     $trim_mode_works = false
 
     # Without
diff --git a/test/filters/test_erubis.rb b/test/filters/test_erubis.rb
index 7fd5780..0c5e726 100644
--- a/test/filters/test_erubis.rb
+++ b/test/filters/test_erubis.rb
@@ -1,8 +1,10 @@
+require 'helper'
+
 class Nanoc::Filters::ErubisTest < Nanoc::TestCase
   def test_filter_with_instance_variable
     if_have 'erubis' do
       # Create filter
-      filter = ::Nanoc::Filters::Erubis.new({ location: 'a cheap motel' })
+      filter = ::Nanoc::Filters::Erubis.new(location: 'a cheap motel')
 
       # Run filter
       result = filter.setup_and_run('<%= "I was hiding in #{@location}." %>')
@@ -13,7 +15,7 @@ class Nanoc::Filters::ErubisTest < Nanoc::TestCase
   def test_filter_with_instance_method
     if_have 'erubis' do
       # Create filter
-      filter = ::Nanoc::Filters::Erubis.new({ location: 'a cheap motel' })
+      filter = ::Nanoc::Filters::Erubis.new(location: 'a cheap motel')
 
       # Run filter
       result = filter.setup_and_run('<%= "I was hiding in #{location}." %>')
@@ -42,7 +44,7 @@ class Nanoc::Filters::ErubisTest < Nanoc::TestCase
   def test_filter_with_yield
     if_have 'erubis' do
       # Create filter
-      filter = ::Nanoc::Filters::Erubis.new({ content: 'a cheap motel' })
+      filter = ::Nanoc::Filters::Erubis.new(content: 'a cheap motel')
 
       # Run filter
       result = filter.setup_and_run('<%= "I was hiding in #{yield}." %>')
@@ -53,7 +55,7 @@ class Nanoc::Filters::ErubisTest < Nanoc::TestCase
   def test_filter_with_yield_without_content
     if_have 'erubis' do
       # Create filter
-      filter = ::Nanoc::Filters::Erubis.new({ location: 'a cheap motel' })
+      filter = ::Nanoc::Filters::Erubis.new(location: 'a cheap motel')
 
       # Run filter
       assert_raises LocalJumpError do
diff --git a/test/filters/test_haml.rb b/test/filters/test_haml.rb
index 1abcc55..f4874b5 100644
--- a/test/filters/test_haml.rb
+++ b/test/filters/test_haml.rb
@@ -1,8 +1,10 @@
+require 'helper'
+
 class Nanoc::Filters::HamlTest < Nanoc::TestCase
   def test_filter
     if_have 'haml' do
       # Create filter
-      filter = ::Nanoc::Filters::Haml.new({ question: 'Is this the Payne residence?' })
+      filter = ::Nanoc::Filters::Haml.new(question: 'Is this the Payne residence?')
 
       # Run filter (no assigns)
       result = filter.setup_and_run('%html')
@@ -21,7 +23,7 @@ class Nanoc::Filters::HamlTest < Nanoc::TestCase
   def test_filter_with_params
     if_have 'haml' do
       # Create filter
-      filter = ::Nanoc::Filters::Haml.new({ foo: 'bar' })
+      filter = ::Nanoc::Filters::Haml.new(foo: 'bar')
 
       # Check with HTML5
       result = filter.setup_and_run('%img', format: :html5)
@@ -36,7 +38,7 @@ class Nanoc::Filters::HamlTest < Nanoc::TestCase
   def test_filter_error
     if_have 'haml' do
       # Create filter
-      filter = ::Nanoc::Filters::Haml.new({ foo: 'bar' })
+      filter = ::Nanoc::Filters::Haml.new(foo: 'bar')
 
       # Run filter
       raised = false
@@ -54,7 +56,7 @@ class Nanoc::Filters::HamlTest < Nanoc::TestCase
   def test_filter_with_yield
     if_have 'haml' do
       # Create filter
-      filter = ::Nanoc::Filters::Haml.new({ content: 'Is this the Payne residence?' })
+      filter = ::Nanoc::Filters::Haml.new(content: 'Is this the Payne residence?')
 
       # Run filter
       result = filter.setup_and_run('%p= yield')
@@ -65,7 +67,7 @@ class Nanoc::Filters::HamlTest < Nanoc::TestCase
   def test_filter_with_yield_without_content
     if_have 'haml' do
       # Create filter
-      filter = ::Nanoc::Filters::Haml.new({ location: 'Is this the Payne residence?' })
+      filter = ::Nanoc::Filters::Haml.new(location: 'Is this the Payne residence?')
 
       # Run filter
       assert_raises LocalJumpError do
diff --git a/test/filters/test_handlebars.rb b/test/filters/test_handlebars.rb
index d974c9c..5ecdca6 100644
--- a/test/filters/test_handlebars.rb
+++ b/test/filters/test_handlebars.rb
@@ -1,5 +1,9 @@
+require 'helper'
+
 class Nanoc::Filters::HandlebarsTest < Nanoc::TestCase
   def test_filter
+    skip_v8_on_ruby24
+
     if_have 'handlebars' do
       # Create data
       item = Nanoc::Int::Item.new(
@@ -34,6 +38,8 @@ class Nanoc::Filters::HandlebarsTest < Nanoc::TestCase
   end
 
   def test_filter_without_layout
+    skip_v8_on_ruby24
+
     if_have 'handlebars' do
       # Create data
       item = Nanoc::Int::Item.new(
diff --git a/test/filters/test_kramdown.rb b/test/filters/test_kramdown.rb
index 8930604..3f9796f 100644
--- a/test/filters/test_kramdown.rb
+++ b/test/filters/test_kramdown.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::KramdownTest < Nanoc::TestCase
   def test_filter
     if_have 'kramdown' do
@@ -12,15 +14,41 @@ class Nanoc::Filters::KramdownTest < Nanoc::TestCase
 
   def test_warnings
     if_have 'kramdown' do
+      # Create item
+      item = Nanoc::Int::Item.new('foo', {}, '/foo.md')
+      item_view = Nanoc::ItemWithRepsView.new(item, nil)
+      item_rep = Nanoc::Int::ItemRep.new(item, :default)
+      item_rep_view = Nanoc::ItemRepView.new(item_rep, nil)
+
       # Create filter
-      filter = ::Nanoc::Filters::Kramdown.new
+      filter = ::Nanoc::Filters::Kramdown.new(item: item_view, item_rep: item_rep_view)
 
       # Run filter
       io = capturing_stdio do
         filter.setup_and_run('{:foo}this is bogus')
       end
       assert_empty io[:stdout]
-      assert_equal "kramdown warning: Found span IAL after text - ignoring it\n", io[:stderr]
+      assert_equal "kramdown warning(s) for #{item_rep_view.inspect}\n  Found span IAL after text - ignoring it\n", io[:stderr]
+    end
+  end
+
+  def test_warning_filters
+    if_have 'kramdown' do
+      # Create item
+      item = Nanoc::Int::Item.new('foo', {}, '/foo.md')
+      item_view = Nanoc::ItemWithRepsView.new(item, nil)
+      item_rep = Nanoc::Int::ItemRep.new(item, :default)
+      item_rep_view = Nanoc::ItemRepView.new(item_rep, nil)
+
+      # Create filter
+      filter = ::Nanoc::Filters::Kramdown.new(item: item_view, item_rep: item_rep_view)
+
+      # Run filter
+      io = capturing_stdio do
+        filter.setup_and_run("{:foo}this is bogus\n[foo]: http://foo.com\n", warning_filters: 'No link definition')
+      end
+      assert_empty io[:stdout]
+      assert_equal "kramdown warning(s) for #{item_rep_view.inspect}\n  Found span IAL after text - ignoring it\n", io[:stderr]
     end
   end
 end
diff --git a/test/filters/test_less.rb b/test/filters/test_less.rb
deleted file mode 100644
index 7e782b4..0000000
--- a/test/filters/test_less.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-class Nanoc::Filters::LessTest < Nanoc::TestCase
-  def view_context
-    dependency_tracker = Nanoc::Int::DependencyTracker.new(nil)
-    Nanoc::ViewContext.new(reps: nil, items: nil, dependency_tracker: dependency_tracker, compiler: nil)
-  end
-
-  def test_filter
-    if_have 'less' do
-      # Create item
-      @item = Nanoc::ItemWithRepsView.new(Nanoc::Int::Item.new('blah', { content_filename: 'content/foo/bar.txt' }, '/foo/bar/'), view_context)
-
-      # Create filter
-      filter = ::Nanoc::Filters::Less.new(item: @item, items: [@item])
-
-      # Run filter
-      result = filter.setup_and_run('.foo { bar: 1 + 1 }')
-      assert_match(/\.foo\s*\{\s*bar:\s*2;?\s*\}/, result)
-    end
-  end
-
-  def test_filter_with_paths_relative_to_site_directory
-    if_have 'less' do
-      # Create file to import
-      FileUtils.mkdir_p('content/foo/bar')
-      File.open('content/foo/bar/imported_file.less', 'w') { |io| io.write('p { color: red; }') }
-
-      # Create item
-      @item = Nanoc::ItemWithRepsView.new(Nanoc::Int::Item.new('blah', { content_filename: 'content/foo/bar.txt' }, '/foo/bar/'), view_context)
-
-      # Create filter
-      filter = ::Nanoc::Filters::Less.new(item: @item, items: [@item])
-
-      # Run filter
-      result = filter.setup_and_run('@import "content/foo/bar/imported_file.less";')
-      assert_match(/p\s*\{\s*color:\s*red;?\s*\}/, result)
-    end
-  end
-
-  def test_filter_with_paths_relative_to_current_file
-    if_have 'less' do
-      # Create file to import
-      FileUtils.mkdir_p('content/foo/bar')
-      File.open('content/foo/bar/imported_file.less', 'w') { |io| io.write('p { color: red; }') }
-
-      # Create item
-      File.open('content/foo/bar.txt', 'w') { |io| io.write('meh') }
-      @item = Nanoc::ItemWithRepsView.new(Nanoc::Int::Item.new('blah', { content_filename: 'content/foo/bar.txt' }, '/foo/bar/'), view_context)
-
-      # Create filter
-      filter = ::Nanoc::Filters::Less.new(item: @item, items: [@item])
-
-      # Run filter
-      result = filter.setup_and_run('@import "bar/imported_file.less";')
-      assert_match(/p\s*\{\s*color:\s*red;?\s*\}/, result)
-    end
-  end
-
-  def test_recompile_includes
-    if_have 'less' do
-      with_site do |site|
-        # Create two less files
-        Dir['content/*'].each { |i| FileUtils.rm(i) }
-        File.open('content/a.less', 'w') do |io|
-          io.write('@import "b.less";')
-        end
-        File.open('content/b.less', 'w') do |io|
-          io.write('p { color: red; }')
-        end
-
-        # Update rules
-        File.open('Rules', 'w') do |io|
-          io.write "compile '*' do\n"
-          io.write "  filter :less\n"
-          io.write "end\n"
-          io.write "\n"
-          io.write "route '/a/' do\n"
-          io.write "  item.identifier.chop + '.css'\n"
-          io.write "end\n"
-          io.write "\n"
-          io.write "route '/b/' do\n"
-          io.write "  nil\n"
-          io.write "end\n"
-        end
-
-        # Compile
-        site = Nanoc::Int::SiteLoader.new.new_from_cwd
-        site.compile
-
-        # Check
-        assert Dir['output/*'].size == 1
-        assert File.file?('output/a.css')
-        refute File.file?('output/b.css')
-        assert_match(/^p\s*\{\s*color:\s*red;?\s*\}/, File.read('output/a.css'))
-
-        # Update included file
-        File.open('content/b.less', 'w') do |io|
-          io.write('p { color: blue; }')
-        end
-
-        # Recompile
-        site = Nanoc::Int::SiteLoader.new.new_from_cwd
-        site.compile
-
-        # Recheck
-        assert Dir['output/*'].size == 1
-        assert File.file?('output/a.css')
-        refute File.file?('output/b.css')
-        assert_match(/^p\s*\{\s*color:\s*blue;?\s*\}/, File.read('output/a.css'))
-      end
-    end
-  end
-
-  def test_compression
-    if_have 'less' do
-      # Create item
-      @item = Nanoc::ItemWithRepsView.new(Nanoc::Int::Item.new('blah', { content_filename: 'content/foo/bar.txt' }, '/foo/bar/'), view_context)
-
-      # Create filter
-      filter = ::Nanoc::Filters::Less.new(item: @item, items: [@item])
-
-      # Run filter with compress option
-      result = filter.setup_and_run('.foo { bar: a; } .bar { foo: b; }', compress: true)
-      assert_match(/^\.foo\{bar:a\}\n?\.bar\{foo:b\}/, result)
-    end
-  end
-end
diff --git a/test/filters/test_markaby.rb b/test/filters/test_markaby.rb
index 54d0561..13d1fb5 100644
--- a/test/filters/test_markaby.rb
+++ b/test/filters/test_markaby.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::MarkabyTest < Nanoc::TestCase
   def test_filter
     if_have 'markaby' do
diff --git a/test/filters/test_maruku.rb b/test/filters/test_maruku.rb
index deaeff6..a56eaf6 100644
--- a/test/filters/test_maruku.rb
+++ b/test/filters/test_maruku.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::MarukuTest < Nanoc::TestCase
   def test_filter
     if_have 'maruku' do
diff --git a/test/filters/test_mustache.rb b/test/filters/test_mustache.rb
index e34bbf8..217f420 100644
--- a/test/filters/test_mustache.rb
+++ b/test/filters/test_mustache.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::MustacheTest < Nanoc::TestCase
   def test_filter
     if_have 'mustache' do
@@ -9,7 +11,7 @@ class Nanoc::Filters::MustacheTest < Nanoc::TestCase
       )
 
       # Create filter
-      filter = ::Nanoc::Filters::Mustache.new({ item: item })
+      filter = ::Nanoc::Filters::Mustache.new(item: item)
 
       # Run filter
       result = filter.setup_and_run('The protagonist of {{title}} is {{protagonist}}.')
@@ -28,7 +30,7 @@ class Nanoc::Filters::MustacheTest < Nanoc::TestCase
 
       # Create filter
       filter = ::Nanoc::Filters::Mustache.new(
-        { content: 'No Payne No Gayne', item: item },
+        content: 'No Payne No Gayne', item: item,
       )
 
       # Run filter
diff --git a/test/filters/test_pandoc.rb b/test/filters/test_pandoc.rb
index 427aa3b..a292002 100644
--- a/test/filters/test_pandoc.rb
+++ b/test/filters/test_pandoc.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::PandocTest < Nanoc::TestCase
   def test_filter
     if_have 'pandoc-ruby' do
diff --git a/test/filters/test_rainpress.rb b/test/filters/test_rainpress.rb
index 1e1752f..5445e24 100644
--- a/test/filters/test_rainpress.rb
+++ b/test/filters/test_rainpress.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RainpressTest < Nanoc::TestCase
   def test_filter
     if_have 'rainpress' do
diff --git a/test/filters/test_rdiscount.rb b/test/filters/test_rdiscount.rb
index 0cfd852..c75dbd6 100644
--- a/test/filters/test_rdiscount.rb
+++ b/test/filters/test_rdiscount.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RDiscountTest < Nanoc::TestCase
   def test_filter
     if_have 'rdiscount' do
diff --git a/test/filters/test_rdoc.rb b/test/filters/test_rdoc.rb
index 64c9f38..baecf97 100644
--- a/test/filters/test_rdoc.rb
+++ b/test/filters/test_rdoc.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RDocTest < Nanoc::TestCase
   def test_filter
     # Get filter
diff --git a/test/filters/test_redcarpet.rb b/test/filters/test_redcarpet.rb
index cf1d8ca..68cdf50 100644
--- a/test/filters/test_redcarpet.rb
+++ b/test/filters/test_redcarpet.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RedcarpetTest < Nanoc::TestCase
   def test_find
     if_have 'redcarpet' do
diff --git a/test/filters/test_redcloth.rb b/test/filters/test_redcloth.rb
index 2269484..5830cde 100644
--- a/test/filters/test_redcloth.rb
+++ b/test/filters/test_redcloth.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RedClothTest < Nanoc::TestCase
   def test_filter
     if_have 'redcloth' do
diff --git a/test/filters/test_relativize_paths.rb b/test/filters/test_relativize_paths.rb
index 36656e0..928113d 100644
--- a/test/filters/test_relativize_paths.rb
+++ b/test/filters/test_relativize_paths.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RelativizePathsTest < Nanoc::TestCase
   def test_filter_html_with_double_quotes
     # Create filter with mock item
@@ -347,6 +349,32 @@ EOS
     assert_match(/<param (name="movie" )?content="..\/..\/example"/, actual_content)
   end
 
+  def test_filter_form
+    # Create filter with mock item
+    filter = Nanoc::Filters::RelativizePaths.new
+
+    # Mock item
+    filter.instance_eval do
+      @item_rep = Nanoc::Int::ItemRep.new(
+        Nanoc::Int::Item.new(
+          'content',
+          {},
+          '/foo/bar/baz/',
+        ),
+        :blah,
+      )
+      @item_rep.paths[:last] = '/foo/bar/baz/'
+    end
+
+    # Set content
+    raw_content      = %(<form action="/example"></form>)
+    expected_content = %(<form action="../../../example"></form>)
+
+    # Test
+    actual_content = filter.setup_and_run(raw_content, type: :html)
+    assert_equal(expected_content, actual_content)
+  end
+
   def test_filter_implicit
     # Create filter with mock item
     filter = Nanoc::Filters::RelativizePaths.new
diff --git a/test/filters/test_rubypants.rb b/test/filters/test_rubypants.rb
index 37be6d5..23f7f03 100644
--- a/test/filters/test_rubypants.rb
+++ b/test/filters/test_rubypants.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::RubyPantsTest < Nanoc::TestCase
   def test_filter
     if_have 'rubypants' do
diff --git a/test/filters/test_sass.rb b/test/filters/test_sass.rb
index 0808d69..034e889 100644
--- a/test/filters/test_sass.rb
+++ b/test/filters/test_sass.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::SassTest < Nanoc::TestCase
   def setup
     super
@@ -12,7 +14,7 @@ class Nanoc::Filters::SassTest < Nanoc::TestCase
   def test_filter
     if_have 'sass' do
       # Get filter
-      filter = create_filter({ foo: 'bar' })
+      filter = create_filter(foo: 'bar')
 
       # Run filter
       result = filter.setup_and_run(".foo #bar\n  color: #f00")
@@ -23,7 +25,7 @@ class Nanoc::Filters::SassTest < Nanoc::TestCase
   def test_filter_with_params
     if_have 'sass' do
       # Create filter
-      filter = create_filter({ foo: 'bar' })
+      filter = create_filter(foo: 'bar')
 
       # Check with compact
       result = filter.setup_and_run(".foo #bar\n  color: #f00", style: 'compact')
diff --git a/test/filters/test_slim.rb b/test/filters/test_slim.rb
index a86241c..29e4000 100644
--- a/test/filters/test_slim.rb
+++ b/test/filters/test_slim.rb
@@ -1,8 +1,10 @@
+require 'helper'
+
 class Nanoc::Filters::SlimTest < Nanoc::TestCase
   def test_filter
     if_have 'slim' do
       # Create filter
-      filter = ::Nanoc::Filters::Slim.new({ rabbit: 'The rabbit is on the branch.' })
+      filter = ::Nanoc::Filters::Slim.new(rabbit: 'The rabbit is on the branch.')
 
       # Run filter (no assigns)
       result = filter.setup_and_run('html')
@@ -20,7 +22,7 @@ class Nanoc::Filters::SlimTest < Nanoc::TestCase
 
   def test_filter_with_yield
     if_have 'slim' do
-      filter = ::Nanoc::Filters::Slim.new({ content: 'The rabbit is on the branch.' })
+      filter = ::Nanoc::Filters::Slim.new(content: 'The rabbit is on the branch.')
 
       result = filter.setup_and_run('p = yield')
       assert_equal('<p>The rabbit is on the branch.</p>', result)
diff --git a/test/filters/test_typogruby.rb b/test/filters/test_typogruby.rb
index 861b77f..9f5740c 100644
--- a/test/filters/test_typogruby.rb
+++ b/test/filters/test_typogruby.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::TypogrubyTest < Nanoc::TestCase
   def test_filter
     if_have 'typogruby' do
diff --git a/test/filters/test_uglify_js.rb b/test/filters/test_uglify_js.rb
index 107d4a0..1e177db 100644
--- a/test/filters/test_uglify_js.rb
+++ b/test/filters/test_uglify_js.rb
@@ -1,5 +1,9 @@
+require 'helper'
+
 class Nanoc::Filters::UglifyJSTest < Nanoc::TestCase
   def test_filter
+    skip_v8_on_ruby24
+
     if_have 'uglifier' do
       # Create filter
       filter = ::Nanoc::Filters::UglifyJS.new
@@ -12,6 +16,8 @@ class Nanoc::Filters::UglifyJSTest < Nanoc::TestCase
   end
 
   def test_filter_with_options
+    skip_v8_on_ruby24
+
     if_have 'uglifier' do
       filter = ::Nanoc::Filters::UglifyJS.new
       input = "if(donkey) alert('It is a donkey!');"
diff --git a/test/filters/test_xsl.rb b/test/filters/test_xsl.rb
index e87454f..330817f 100644
--- a/test/filters/test_xsl.rb
+++ b/test/filters/test_xsl.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 require 'tempfile'
 
 class Nanoc::Filters::XSLTest < Nanoc::TestCase
@@ -81,13 +83,33 @@ EOS
 
   SAMPLE_XML_OUT_WITH_OMIT_XML_DECL = %r{\A<html>\s*<head>\s*<title>My Report</title>\s*</head>\s*<body>\s*<h1>My Report</h1>\s*</body>\s*</html>\s*\Z}m
 
+  def setup
+    super
+
+    @dependency_store = Nanoc::Int::DependencyStore.new([])
+    @dependency_tracker = Nanoc::Int::DependencyTracker.new(@dependency_store)
+
+    @base_item = Nanoc::Int::Item.new('base', {}, '/base.md')
+
+    @dependency_tracker.enter(@base_item)
+  end
+
+  def new_view_context
+    Nanoc::ViewContext.new(
+      reps: :__irrelevat_reps,
+      items: :__irrelevat_items,
+      dependency_tracker: @dependency_tracker,
+      compilation_context: :__irrelevat_compiler,
+    )
+  end
+
   def test_filter_as_layout
     if_have 'nokogiri' do
       # Create our data objects
       item = Nanoc::Int::Item.new(SAMPLE_XML_IN, {}, '/content/')
-      item = Nanoc::ItemWithRepsView.new(item, nil)
+      item = Nanoc::ItemWithRepsView.new(item, new_view_context)
       layout = Nanoc::Int::Layout.new(SAMPLE_XSL, {}, '/layout/')
-      layout = Nanoc::LayoutView.new(layout, nil)
+      layout = Nanoc::LayoutView.new(layout, new_view_context)
 
       # Create an instance of the filter
       assigns = {
@@ -100,6 +122,10 @@ EOS
       # Run the filter and validate the results
       result = filter.setup_and_run(layout.raw_content)
       assert_match SAMPLE_XML_OUT, result
+
+      # Verify dependencies
+      dep = @dependency_store.dependencies_causing_outdatedness_of(@base_item)[0]
+      refute_nil dep
     end
   end
 
@@ -107,9 +133,9 @@ EOS
     if_have 'nokogiri' do
       # Create our data objects
       item = Nanoc::Int::Item.new(SAMPLE_XML_IN_WITH_PARAMS, {}, '/content/')
-      item = Nanoc::ItemWithRepsView.new(item, nil)
+      item = Nanoc::ItemWithRepsView.new(item, new_view_context)
       layout = Nanoc::Int::Layout.new(SAMPLE_XSL_WITH_PARAMS, {}, '/layout/')
-      layout = Nanoc::LayoutView.new(layout, nil)
+      layout = Nanoc::LayoutView.new(layout, new_view_context)
 
       # Create an instance of the filter
       assigns = {
@@ -122,6 +148,10 @@ EOS
       # Run the filter and validate the results
       result = filter.setup_and_run(layout.raw_content, foo: 'bar')
       assert_match SAMPLE_XML_OUT_WITH_PARAMS, result
+
+      # Verify dependencies
+      dep = @dependency_store.dependencies_causing_outdatedness_of(@base_item)[0]
+      refute_nil dep
     end
   end
 
@@ -129,9 +159,9 @@ EOS
     if_have 'nokogiri' do
       # Create our data objects
       item = Nanoc::Int::Item.new(SAMPLE_XML_IN_WITH_OMIT_XML_DECL, {}, '/content/')
-      item = Nanoc::ItemWithRepsView.new(item, nil)
+      item = Nanoc::ItemWithRepsView.new(item, new_view_context)
       layout = Nanoc::Int::Layout.new(SAMPLE_XSL_WITH_OMIT_XML_DECL, {}, '/layout/')
-      layout = Nanoc::LayoutView.new(layout, nil)
+      layout = Nanoc::LayoutView.new(layout, new_view_context)
 
       # Create an instance of the filter
       assigns = {
@@ -144,6 +174,10 @@ EOS
       # Run the filter and validate the results
       result = filter.setup_and_run(layout.raw_content)
       assert_match SAMPLE_XML_OUT_WITH_OMIT_XML_DECL, result
+
+      # Verify dependencies
+      dep = @dependency_store.dependencies_causing_outdatedness_of(@base_item)[0]
+      refute_nil dep
     end
   end
 end
diff --git a/test/filters/test_yui_compressor.rb b/test/filters/test_yui_compressor.rb
index 68850ff..14089dc 100644
--- a/test/filters/test_yui_compressor.rb
+++ b/test/filters/test_yui_compressor.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Filters::YUICompressorTest < Nanoc::TestCase
   def test_filter_javascript
     if_have 'yuicompressor' do
@@ -13,10 +15,10 @@ class Nanoc::Filters::YUICompressorTest < Nanoc::TestCase
         }
       JAVASCRIPT
 
-      result = filter.setup_and_run(sample_js, { type: 'js', munge: true })
+      result = filter.setup_and_run(sample_js, type: 'js', munge: true)
       assert_match 'function factorial(c){var a=1;for(var b=2;b<=c;b++){a*=b}return a};', result
 
-      result = filter.setup_and_run(sample_js, { type: 'js', munge: false })
+      result = filter.setup_and_run(sample_js, type: 'js', munge: false)
       assert_match 'function factorial(n){var result=1;for(var i=2;i<=n;i++){result*=i}return result};', result
     end
   end
@@ -31,7 +33,7 @@ class Nanoc::Filters::YUICompressorTest < Nanoc::TestCase
         }
       CSS
 
-      result = filter.setup_and_run(sample_css, { type: 'css' })
+      result = filter.setup_and_run(sample_css, type: 'css')
       assert_match '*{margin:0}', result
     end
   end
diff --git a/test/fixtures/vcr_cassettes/css_run_error.yml b/test/fixtures/vcr_cassettes/css_run_error.yml
index 6e0cb13..793a154 100644
--- a/test/fixtures/vcr_cassettes/css_run_error.yml
+++ b/test/fixtures/vcr_cassettes/css_run_error.yml
@@ -1,12 +1,17 @@
 ---
 http_interactions:
 - request:
-    method: get
-    uri: http://jigsaw.w3.org/css-validator/validator?output=soap12&profile=css3&text=h1%20%7B%20coxlor:%20rxed%3B%20%7D
+    method: post
+    uri: http://jigsaw.w3.org/css-validator/validator
     body:
-      encoding: US-ASCII
-      string: ''
+      encoding: UTF-8
+      string: "--349832898984244898448024464570528145\r\nContent-Disposition: form-data;
+        name=\"profile\"\r\n\r\ncss3\r\n--349832898984244898448024464570528145\r\nContent-Disposition:
+        form-data; name=\"text\"\r\n\r\nh1 { coxlor: rxed; }\r\n--349832898984244898448024464570528145\r\nContent-Disposition:
+        form-data; name=\"output\"\r\n\r\nsoap12\r\n--349832898984244898448024464570528145--\r\n"
     headers:
+      Content-Type:
+      - multipart/form-data; boundary=349832898984244898448024464570528145
       Accept-Encoding:
       - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
       Accept:
@@ -21,7 +26,7 @@ http_interactions:
       Cache-Control:
       - no-cache
       Date:
-      - Thu, 24 Apr 2014 07:25:20 GMT
+      - Sat, 17 Dec 2016 08:33:23 GMT
       Pragma:
       - no-cache
       Transfer-Encoding:
@@ -42,9 +47,9 @@ http_interactions:
       encoding: UTF-8
       string: "<?xml version='1.0' encoding=\"utf-8\"?>\n<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">\n
         \   <env:Body>\n        <m:cssvalidationresponse\n            env:encodingStyle=\"http://www.w3.org/2003/05/soap-encoding\"\n
-        \           xmlns:m=\"http://www.w3.org/2005/07/css-validator\">\n            <m:uri>file://localhost/TextArea</m:uri>\n
+        \           xmlns:m=\"http://www.w3.org/2005/07/css-validator\">\n            <m:uri>TextArea</m:uri>\n
         \           <m:checkedby>http://jigsaw.w3.org/css-validator/</m:checkedby>\n
-        \           <m:csslevel>css3</m:csslevel>\n            <m:date>2014-04-24T07:25:20Z</m:date>\n
+        \           <m:csslevel>css3</m:csslevel>\n            <m:date>2016-12-17T08:33:23Z</m:date>\n
         \           <m:validity>false</m:validity>\n            <m:result>\n                <m:errors
         xml:lang=\"en\">\n                    <m:errorcount>1</m:errorcount>\n                                                                    \n
         \               <m:errorlist>\n                    <m:uri>file://localhost/TextArea</m:uri>\n
@@ -61,5 +66,5 @@ http_interactions:
         \               </m:warnings>\n            </m:result>\n        </m:cssvalidationresponse>\n
         \   </env:Body>\n</env:Envelope>\n\n"
     http_version: 
-  recorded_at: Thu, 24 Apr 2014 07:25:20 GMT
-recorded_with: VCR 2.9.0
+  recorded_at: Sat, 17 Dec 2016 08:33:24 GMT
+recorded_with: VCR 3.0.3
diff --git a/test/fixtures/vcr_cassettes/css_run_ok.yml b/test/fixtures/vcr_cassettes/css_run_ok.yml
index bcad08c..b85e0c1 100644
--- a/test/fixtures/vcr_cassettes/css_run_ok.yml
+++ b/test/fixtures/vcr_cassettes/css_run_ok.yml
@@ -1,12 +1,17 @@
 ---
 http_interactions:
 - request:
-    method: get
-    uri: http://jigsaw.w3.org/css-validator/validator?output=soap12&profile=css3&text=h1%20%7B%20color:%20red%3B%20%7D
+    method: post
+    uri: http://jigsaw.w3.org/css-validator/validator
     body:
-      encoding: US-ASCII
-      string: ''
+      encoding: UTF-8
+      string: "--349832898984244898448024464570528145\r\nContent-Disposition: form-data;
+        name=\"profile\"\r\n\r\ncss3\r\n--349832898984244898448024464570528145\r\nContent-Disposition:
+        form-data; name=\"text\"\r\n\r\nh1 { color: red; }\r\n--349832898984244898448024464570528145\r\nContent-Disposition:
+        form-data; name=\"output\"\r\n\r\nsoap12\r\n--349832898984244898448024464570528145--\r\n"
     headers:
+      Content-Type:
+      - multipart/form-data; boundary=349832898984244898448024464570528145
       Accept-Encoding:
       - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
       Accept:
@@ -21,7 +26,7 @@ http_interactions:
       Cache-Control:
       - no-cache
       Date:
-      - Thu, 24 Apr 2014 07:25:20 GMT
+      - Sat, 17 Dec 2016 08:33:24 GMT
       Pragma:
       - no-cache
       Transfer-Encoding:
@@ -31,7 +36,7 @@ http_interactions:
       Content-Type:
       - application/soap+xml;charset=utf-8
       Server:
-      - Jigsaw/2.3.0-beta3
+      - Jigsaw/2.3.0-beta2
       Vary:
       - Accept-Language
       X-W3c-Validator-Errors:
@@ -42,14 +47,14 @@ http_interactions:
       encoding: UTF-8
       string: "<?xml version='1.0' encoding=\"utf-8\"?>\n<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">\n
         \   <env:Body>\n        <m:cssvalidationresponse\n            env:encodingStyle=\"http://www.w3.org/2003/05/soap-encoding\"\n
-        \           xmlns:m=\"http://www.w3.org/2005/07/css-validator\">\n            <m:uri>file://localhost/TextArea</m:uri>\n
+        \           xmlns:m=\"http://www.w3.org/2005/07/css-validator\">\n            <m:uri>TextArea</m:uri>\n
         \           <m:checkedby>http://jigsaw.w3.org/css-validator/</m:checkedby>\n
-        \           <m:csslevel>css3</m:csslevel>\n            <m:date>2014-04-24T07:25:20Z</m:date>\n
+        \           <m:csslevel>css3</m:csslevel>\n            <m:date>2016-12-17T08:33:24Z</m:date>\n
         \           <m:validity>true</m:validity>\n            <m:result>\n                <m:errors
         xml:lang=\"en\">\n                    <m:errorcount>0</m:errorcount>\n    \n
         \               </m:errors>\n                <m:warnings xml:lang=\"en\">\n
         \                   <m:warningcount>0</m:warningcount>\n                </m:warnings>\n
         \           </m:result>\n        </m:cssvalidationresponse>\n    </env:Body>\n</env:Envelope>\n\n"
     http_version: 
-  recorded_at: Thu, 24 Apr 2014 07:25:20 GMT
-recorded_with: VCR 2.9.0
+  recorded_at: Sat, 17 Dec 2016 08:33:24 GMT
+recorded_with: VCR 3.0.3
diff --git a/test/fixtures/vcr_cassettes/css_run_parse_error.yml b/test/fixtures/vcr_cassettes/css_run_parse_error.yml
index 83d6134..30d5968 100644
--- a/test/fixtures/vcr_cassettes/css_run_parse_error.yml
+++ b/test/fixtures/vcr_cassettes/css_run_parse_error.yml
@@ -1,12 +1,17 @@
 ---
 http_interactions:
 - request:
-    method: get
-    uri: http://jigsaw.w3.org/css-validator/validator?output=soap12&profile=css3&text=h1%20%7B%20%3B%20%7B
+    method: post
+    uri: http://jigsaw.w3.org/css-validator/validator
     body:
-      encoding: US-ASCII
-      string: ''
+      encoding: UTF-8
+      string: "--349832898984244898448024464570528145\r\nContent-Disposition: form-data;
+        name=\"profile\"\r\n\r\ncss3\r\n--349832898984244898448024464570528145\r\nContent-Disposition:
+        form-data; name=\"text\"\r\n\r\nh1 { ; {\r\n--349832898984244898448024464570528145\r\nContent-Disposition:
+        form-data; name=\"output\"\r\n\r\nsoap12\r\n--349832898984244898448024464570528145--\r\n"
     headers:
+      Content-Type:
+      - multipart/form-data; boundary=349832898984244898448024464570528145
       Accept-Encoding:
       - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
       Accept:
@@ -21,7 +26,7 @@ http_interactions:
       Cache-Control:
       - no-cache
       Date:
-      - Sat, 06 Dec 2014 11:12:00 GMT
+      - Sat, 17 Dec 2016 08:33:24 GMT
       Pragma:
       - no-cache
       Transfer-Encoding:
@@ -31,7 +36,7 @@ http_interactions:
       Content-Type:
       - application/soap+xml;charset=utf-8
       Server:
-      - Jigsaw/2.3.0-beta2
+      - Jigsaw/2.3.0-beta3
       Vary:
       - Accept-Language
       X-W3c-Validator-Errors:
@@ -42,9 +47,9 @@ http_interactions:
       encoding: UTF-8
       string: "<?xml version='1.0' encoding=\"utf-8\"?>\n<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">\n
         \   <env:Body>\n        <m:cssvalidationresponse\n            env:encodingStyle=\"http://www.w3.org/2003/05/soap-encoding\"\n
-        \           xmlns:m=\"http://www.w3.org/2005/07/css-validator\">\n            <m:uri>file://localhost/TextArea</m:uri>\n
+        \           xmlns:m=\"http://www.w3.org/2005/07/css-validator\">\n            <m:uri>TextArea</m:uri>\n
         \           <m:checkedby>http://jigsaw.w3.org/css-validator/</m:checkedby>\n
-        \           <m:csslevel>css3</m:csslevel>\n            <m:date>2014-12-06T11:12:00Z</m:date>\n
+        \           <m:csslevel>css3</m:csslevel>\n            <m:date>2016-12-17T08:33:24Z</m:date>\n
         \           <m:validity>false</m:validity>\n            <m:result>\n                <m:errors
         xml:lang=\"en\">\n                    <m:errorcount>1</m:errorcount>\n                                                                    \n
         \               <m:errorlist>\n                    <m:uri>file://localhost/TextArea</m:uri>\n
@@ -61,5 +66,5 @@ http_interactions:
         \               </m:warnings>\n            </m:result>\n        </m:cssvalidationresponse>\n
         \   </env:Body>\n</env:Envelope>\n\n"
     http_version: 
-  recorded_at: Sat, 06 Dec 2014 11:12:00 GMT
-recorded_with: VCR 2.9.3
+  recorded_at: Sat, 17 Dec 2016 08:33:24 GMT
+recorded_with: VCR 3.0.3
diff --git a/test/fixtures/vcr_cassettes/html_run_error.yml b/test/fixtures/vcr_cassettes/html_run_error.yml
index ed954c4..faa43d8 100644
--- a/test/fixtures/vcr_cassettes/html_run_error.yml
+++ b/test/fixtures/vcr_cassettes/html_run_error.yml
@@ -2,7 +2,7 @@
 http_interactions:
 - request:
     method: post
-    uri: http://validator.w3.org/check
+    uri: https://validator.w3.org/check
     body:
       encoding: UTF-8
       string: "--349832898984244898448024464570528145\r\nContent-Disposition: form-data;
@@ -24,9 +24,9 @@ http_interactions:
       message: OK
     headers:
       Date:
-      - Thu, 24 Apr 2014 07:25:20 GMT
+      - Sat, 17 Dec 2016 08:33:18 GMT
       Server:
-      - Apache/2.2.16 (Debian)
+      - Apache/2.4.10 (Debian)
       X-W3c-Validator-Recursion:
       - '1'
       X-W3c-Validator-Status:
@@ -35,23 +35,38 @@ http_interactions:
       - '2'
       X-W3c-Validator-Warnings:
       - '4'
-      Content-Type:
-      - application/soap+xml; charset=UTF-8
-      Connection:
-      - close
       Transfer-Encoding:
       - chunked
+      Content-Type:
+      - application/soap+xml; charset=UTF-8
+      Strict-Transport-Security:
+      - max-age=15552015; preload
+      Public-Key-Pins:
+      - pin-sha256="cN0QSpPIkuwpT6iP2YjEo1bEwGpH/yiUn6yhdy+HNto="; pin-sha256="WGJkyYjx1QMdMe0UqlyOKXtydPDVrk7sl2fV+nNm1r4=";
+        pin-sha256="LrKdTxZLRTvyHM4/atX2nquX9BeHRZMCxg3cf4rhc2I="; max-age=864000
+      X-Frame-Options:
+      - deny
+      X-Xss-Protection:
+      - 1; mode=block
     body:
       encoding: UTF-8
       string: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">\n<env:Body>\n<m:markupvalidationresponse
         env:encodingStyle=\"http://www.w3.org/2003/05/soap-encoding\" xmlns:m=\"http://www.w3.org/2005/10/markup-validator\">\n
         \   \n    <m:uri>output/blah.html</m:uri>\n    <m:checkedby>http://validator.w3.org/</m:checkedby>\n
         \   <m:doctype></m:doctype>\n    <m:charset>utf-8</m:charset>\n    <m:validity>false</m:validity>\n
-        \   <m:errors>\n        <m:errorcount>2</m:errorcount>\n        <m:errorlist>\n
-        \         \n            <m:error>\n                <m:line>1</m:line>\n                <m:col>1</m:col>\n
-        \               <m:message>no document type declaration; will parse without
-        validation</m:message>\n                <m:messageid>187</m:messageid>\n                <m:explanation>
-        \ <![CDATA[\n                      <p class=\"helpwanted\">\n      <a\n        href=\"feedback.html?uri=;errmsg_id=187#errormsg\"\n\ttitle=\"Suggest
+        \   <m:warnings>\n        <m:warningcount>4</m:warningcount>\n        <m:warninglist>\n
+        \          \n\n\n\n\n  <m:warning><m:messageid>W04</m:messageid><m:message>No
+        Character Encoding Found!\n    \n      Falling back to \n    \n    UTF-8.\n
+        \ </m:message></m:warning>\n\n\n\n  <m:warning><m:messageid>W06</m:messageid><m:message>Unable
+        to Determine Parse Mode!</m:message></m:warning>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
+        \ <m:warning><m:messageid>W27</m:messageid><m:message>No Character encoding
+        declared at document level</m:message></m:warning>\n\n\n\n\n\n\n\n        </m:warninglist>\n
+        \   </m:warnings>\n    <m:errors>\n        <m:errorcount>2</m:errorcount>\n
+        \       <m:errorlist>\n\n            <m:error>\n                <m:line>1</m:line>\n
+        \               <m:col>1</m:col>\n                <m:message>no document type
+        declaration; will parse without validation</m:message>\n                <m:messageid>187</m:messageid>\n
+        \               <m:explanation>  <![CDATA[\n                      <p class=\"helpwanted\">\n
+        \     <a\n        href=\"feedback.html?uri=;errmsg_id=187#errormsg\"\n\ttitle=\"Suggest
         improvements on this error message through our feedback channels\" \n      >&#x2709;</a>\n
         \   </p>\n\n    <div class=\"ve mid-187\">\n    <p>The document type could
         not be determined, because the document had no correct DOCTYPE declaration.
@@ -81,14 +96,7 @@ http_interactions:
         entry</a>.\n    </p>\n  </div>\n\n                  ]]>\n                </m:explanation>\n
         \               <m:source><![CDATA[<h2>Hi!</h1<strong title=\"Position
         where error was detected.\">></strong>]]></m:source>\n            </m:error>\n
-        \          \n        </m:errorlist>\n    </m:errors>\n    <m:warnings>\n        <m:warningcount>4</m:warningcount>\n
-        \       <m:warninglist>\n        \n\n\n\n  <m:warning><m:messageid>W04</m:messageid><m:message>No
-        Character Encoding Found!\n    \n      Falling back to \n    \n    UTF-8.\n
-        \ </m:message></m:warning>\n\n\n\n  <m:warning><m:messageid>W06</m:messageid><m:message>Unable
-        to Determine Parse Mode!</m:message></m:warning>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
-        \ <m:warning><m:messageid>W27</m:messageid><m:message>No Character encoding
-        declared at document level</m:message></m:warning>\n\n\n\n\n\n\n\n        \n
-        \       </m:warninglist>\n    </m:warnings>\n</m:markupvalidationresponse>\n</env:Body>\n</env:Envelope>\n"
+        \          \n        </m:errorlist>\n    </m:errors>\n</m:markupvalidationresponse>\n</env:Body>\n</env:Envelope>\n"
     http_version: 
-  recorded_at: Thu, 24 Apr 2014 07:25:21 GMT
-recorded_with: VCR 2.9.0
+  recorded_at: Sat, 17 Dec 2016 08:33:19 GMT
+recorded_with: VCR 3.0.3
diff --git a/test/fixtures/vcr_cassettes/html_run_ok.yml b/test/fixtures/vcr_cassettes/html_run_ok.yml
index 45799d8..4728059 100644
--- a/test/fixtures/vcr_cassettes/html_run_ok.yml
+++ b/test/fixtures/vcr_cassettes/html_run_ok.yml
@@ -2,7 +2,7 @@
 http_interactions:
 - request:
     method: post
-    uri: http://validator.w3.org/check
+    uri: https://validator.w3.org/check
     body:
       encoding: UTF-8
       string: "--349832898984244898448024464570528145\r\nContent-Disposition: form-data;
@@ -20,37 +20,105 @@ http_interactions:
       - Ruby
   response:
     status:
-      code: 200
-      message: OK
+      code: 307
+      message: Temporary Redirect
     headers:
       Date:
-      - Thu, 24 Apr 2014 07:25:21 GMT
+      - Sat, 17 Dec 2016 08:36:04 GMT
       Server:
-      - Apache/2.2.16 (Debian)
-      X-W3c-Validator-Recursion:
-      - '1'
-      X-W3c-Validator-Status:
-      - Valid
-      X-W3c-Validator-Errors:
-      - '0'
-      X-W3c-Validator-Warnings:
-      - '1'
-      Content-Type:
-      - application/soap+xml; charset=UTF-8
-      Connection:
-      - close
+      - Apache/2.4.10 (Debian)
+      Location:
+      - https://validator.w3.org/nu/#file
       Transfer-Encoding:
       - chunked
+      Strict-Transport-Security:
+      - max-age=15552015; preload
+      Public-Key-Pins:
+      - pin-sha256="cN0QSpPIkuwpT6iP2YjEo1bEwGpH/yiUn6yhdy+HNto="; pin-sha256="WGJkyYjx1QMdMe0UqlyOKXtydPDVrk7sl2fV+nNm1r4=";
+        pin-sha256="LrKdTxZLRTvyHM4/atX2nquX9BeHRZMCxg3cf4rhc2I="; max-age=864000
+      X-Frame-Options:
+      - deny
+      X-Xss-Protection:
+      - 1; mode=block
     body:
       encoding: UTF-8
-      string: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">\n<env:Body>\n<m:markupvalidationresponse
+      string: "Status: 302 Found\r\nLocation: https://validator.w3.org/nu/?doc=output%2Fblah.html\r\n\r\nContent-Type:
+        application/soap+xml; charset=UTF-8\nX-W3C-Validator-Recursion: 1\nX-W3C-Validator-Status:
+        Valid\nX-W3C-Validator-Errors: 0\nX-W3C-Validator-Warnings: 1\n\n<?xml version=\"1.0\"
+        encoding=\"UTF-8\"?>\n<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">\n<env:Body>\n<m:markupvalidationresponse
         env:encodingStyle=\"http://www.w3.org/2003/05/soap-encoding\" xmlns:m=\"http://www.w3.org/2005/10/markup-validator\">\n
         \   \n    <m:uri>output/blah.html</m:uri>\n    <m:checkedby>http://validator.w3.org/</m:checkedby>\n
         \   <m:doctype>HTML5</m:doctype>\n    <m:charset>utf-8</m:charset>\n    <m:validity>true</m:validity>\n
-        \   <m:errors>\n        <m:errorcount>0</m:errorcount>\n        <m:errorlist>\n
-        \         \n        </m:errorlist>\n    </m:errors>\n    <m:warnings>\n        <m:warningcount>1</m:warningcount>\n
-        \       <m:warninglist>\n        \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
-        \       \n        </m:warninglist>\n    </m:warnings>\n</m:markupvalidationresponse>\n</env:Body>\n</env:Envelope>\n"
+        \   <m:warnings>\n        <m:warningcount>1</m:warningcount>\n        <m:warninglist>\n
+        \            <m:warning>\n                <m:message>This interface to HTML5
+        document checking is obsolete. Use an interface to https://validator.w3.org/nu/
+        instead.</m:message>\n                <m:messageid>obsolete-interface</m:messageid>\n
+        \               <m:explanation>  <![CDATA[\n                    <div class=\"ve
+        obsolete-interface\">\n                      <p>\n                        The
+        method you used to check this document relies on an obsolete interface\n                        that
+        will become permanently unavailable in the near future.\n                        The
+        currently-supported method is to instead use the W3C Nu Html Checker at\n
+        \                       <a href=\"https://validator.w3.org/nu/\">https://validator.w3.org/nu/</a>.\n
+        \                       See the documentation on the W3C Nu Html Checker's\n
+        \                       <a href=\"https://github.com/validator/validator/wiki/Service:-Input:-GET\">GET
+        interface</a>,\n                        <a href=\"https://github.com/validator/validator/wiki/Service:-Input:-POST-body\">POST
+        interface</a>,\n                        and\n                        <a href=\"https://github.com/validator/validator/wiki/Service:-Common-parameters\">the
+        parameters</a>\n                        that both interfaces provide, such
+        as the\n                        <a href=\"https://github.com/validator/validator/wiki/Output%3A-JSON\">out=json</a>,\n
+        \                       <a href=\"https://github.com/validator/validator/wiki/Output:-XML\">out=xml</a>,\n
+        \                       and\n                        <a href=\"https://github.com/validator/validator/wiki/Output:-GNU\">out=gnu</a>\n
+        \                       parameters/output-formats.\n                      </p>\n
+        \                     <p>\n                        For comments or questions
+        about this message, send mail to\n                        <a href=\"mailto:www-validator at w3.org\">www-validator at w3.org</a>\n
+        \                       or to\n                        <a href=\"https://twitter.com/w3c\">@w3c</a>
+        on twitter.\n                      </p>\n                    </div>\n                  ]]>\n
+        \               </m:explanation>\n                <m:source></m:source>\n
+        \           </m:warning>\n        </m:warninglist>\n    </m:warnings>\n    <m:errors>\n
+        \       <m:errorcount>0</m:errorcount>\n        <m:errorlist>\n\n        </m:errorlist>\n
+        \   </m:errors>\n</m:markupvalidationresponse>\n</env:Body>\n</env:Envelope>\n"
+    http_version: 
+  recorded_at: Sat, 17 Dec 2016 08:36:05 GMT
+- request:
+    method: post
+    uri: https://validator.w3.org/check
+    body:
+      encoding: UTF-8
+      string: "--349832898984244898448024464570528145\r\nContent-Disposition: form-data;
+        name=\"url\"\r\n\r\nhttps://validator.w3.org/nu/#file\r\n--349832898984244898448024464570528145--\r\n"
+    headers:
+      Content-Type:
+      - multipart/form-data; boundary=349832898984244898448024464570528145
+      Accept-Encoding:
+      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+      Accept:
+      - "*/*"
+      User-Agent:
+      - Ruby
+  response:
+    status:
+      code: 302
+      message: Found
+    headers:
+      Date:
+      - Sat, 17 Dec 2016 08:36:05 GMT
+      Server:
+      - Apache/2.4.10 (Debian)
+      Location:
+      - http://validator.w3.org/check?uri=https%3A%2F%2Fvalidator.w3.org%2Fnu%2F%23file
+      Content-Length:
+      - '0'
+      Strict-Transport-Security:
+      - max-age=15552015; preload
+      Public-Key-Pins:
+      - pin-sha256="cN0QSpPIkuwpT6iP2YjEo1bEwGpH/yiUn6yhdy+HNto="; pin-sha256="WGJkyYjx1QMdMe0UqlyOKXtydPDVrk7sl2fV+nNm1r4=";
+        pin-sha256="LrKdTxZLRTvyHM4/atX2nquX9BeHRZMCxg3cf4rhc2I="; max-age=864000
+      X-Frame-Options:
+      - deny
+      X-Xss-Protection:
+      - 1; mode=block
+    body:
+      encoding: UTF-8
+      string: ''
     http_version: 
-  recorded_at: Thu, 24 Apr 2014 07:25:21 GMT
-recorded_with: VCR 2.9.0
+  recorded_at: Sat, 17 Dec 2016 08:36:06 GMT
+recorded_with: VCR 3.0.3
diff --git a/test/helper.rb b/test/helper.rb
index 7725104..d60aa5c 100644
--- a/test/helper.rb
+++ b/test/helper.rb
@@ -28,6 +28,12 @@ module Nanoc::TestHelpers
     ENV.key?('DISABLE_NOKOGIRI')
   end
 
+  def skip_v8_on_ruby24
+    if ENV.key?('DISABLE_V8')
+      skip 'V8 specs are disabled (broken on Ruby 2.4)'
+    end
+  end
+
   def if_have(*libs)
     libs.each do |lib|
       if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby' && lib == 'nokogiri' && disable_nokogiri?
diff --git a/test/helpers/test_blogging.rb b/test/helpers/test_blogging.rb
index b8eedd4..0c07a4c 100644
--- a/test/helpers/test_blogging.rb
+++ b/test/helpers/test_blogging.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Helpers::BloggingTest < Nanoc::TestCase
   include Nanoc::Helpers::Blogging
   include Nanoc::Helpers::Text
@@ -93,6 +95,68 @@ class Nanoc::Helpers::BloggingTest < Nanoc::TestCase
     end
   end
 
+  def test_atom_feed_updated_is_most_recent
+    if_have 'builder' do
+      # Create items
+      @items = [mock_item, mock_article, mock_article]
+
+      # Create item 1
+      @items[1].stubs(:[]).with(:updated_at).returns(nil)
+      @items[1].stubs(:[]).with(:created_at).returns(Time.parse('2016-12-01 17:20:00 +00:00'))
+      @items[1].expects(:compiled_content).returns('item 1 content')
+
+      # Create item 2
+      @items[2].stubs(:[]).with(:updated_at).returns(nil)
+      @items[2].stubs(:[]).with(:created_at).returns(Time.parse('2016-12-01 18:40:00 +00:00'))
+      @items[2].expects(:compiled_content).returns('item 2 content')
+
+      # Mock site
+      @config = Nanoc::ConfigView.new({ base_url: 'http://example.com' }, nil)
+
+      # Create feed item
+      @item = mock
+      @item.stubs(:[]).with(:title).returns('My Cool Blog')
+      @item.stubs(:[]).with(:author_name).returns('Denis Defreyne')
+      @item.stubs(:[]).with(:author_uri).returns('http://stoneship.org/')
+      @item.stubs(:[]).with(:feed_url).returns(nil)
+      @item.stubs(:path).returns('/journal/feed/')
+
+      # Check
+      assert_match(%r{<title>My Cool Blog</title>\n  <updated>2016-12-01T18:40:00Z</updated>}, atom_feed)
+    end
+  end
+
+  def test_atom_feed_updated_is_most_recent_updated_at
+    if_have 'builder' do
+      # Create items
+      @items = [mock_item, mock_article, mock_article]
+
+      # Create item 1
+      @items[1].stubs(:[]).with(:updated_at).returns(Time.parse('2016-12-01 19:20:00 +00:00'))
+      @items[1].stubs(:[]).with(:created_at).returns(Time.parse('2016-12-01 17:20:00 +00:00'))
+      @items[1].expects(:compiled_content).returns('item 1 content')
+
+      # Create item 2
+      @items[2].stubs(:[]).with(:updated_at).returns(Time.parse('2016-12-01 20:40:00 +00:00'))
+      @items[2].stubs(:[]).with(:created_at).returns(Time.parse('2016-12-01 18:40:00 +00:00'))
+      @items[2].expects(:compiled_content).returns('item 2 content')
+
+      # Mock site
+      @config = Nanoc::ConfigView.new({ base_url: 'http://example.com' }, nil)
+
+      # Create feed item
+      @item = mock
+      @item.stubs(:[]).with(:title).returns('My Cool Blog')
+      @item.stubs(:[]).with(:author_name).returns('Denis Defreyne')
+      @item.stubs(:[]).with(:author_uri).returns('http://stoneship.org/')
+      @item.stubs(:[]).with(:feed_url).returns(nil)
+      @item.stubs(:path).returns('/journal/feed/')
+
+      # Check
+      assert_match(%r{<title>My Cool Blog</title>\n  <updated>2016-12-01T20:40:00Z</updated>}, atom_feed)
+    end
+  end
+
   def test_atom_feed_without_articles
     if_have 'builder' do
       # Mock items
diff --git a/test/helpers/test_capturing.rb b/test/helpers/test_capturing.rb
index b11926a..1b4fba8 100644
--- a/test/helpers/test_capturing.rb
+++ b/test/helpers/test_capturing.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Helpers::CapturingTest < Nanoc::TestCase
   include Nanoc::Helpers::Capturing
 
@@ -12,10 +14,15 @@ class Nanoc::Helpers::CapturingTest < Nanoc::TestCase
       reps: item_rep_repo_for(item),
       items: :__irrelevant__,
       dependency_tracker: :__irrelevant__,
-      compiler: :__irrelevant__,
+      compilation_context: :__irrelevant__,
     )
   end
 
+  def before
+    super
+    Nanoc::CLI::ErrorHandler.enable
+  end
+
   def test_dependencies
     with_site do |_site|
       # Prepare
diff --git a/test/helpers/test_link_to.rb b/test/helpers/test_link_to.rb
index a54aabe..e75ac46 100644
--- a/test/helpers/test_link_to.rb
+++ b/test/helpers/test_link_to.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Helpers::LinkToTest < Nanoc::TestCase
   include Nanoc::Helpers::LinkTo
 
diff --git a/test/helpers/test_xml_sitemap.rb b/test/helpers/test_xml_sitemap.rb
index b4f27d9..e58414f 100644
--- a/test/helpers/test_xml_sitemap.rb
+++ b/test/helpers/test_xml_sitemap.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Helpers::XMLSitemapTest < Nanoc::TestCase
   include Nanoc::Helpers::XMLSitemap
 
@@ -6,7 +8,7 @@ class Nanoc::Helpers::XMLSitemapTest < Nanoc::TestCase
 
     @reps = Nanoc::Int::ItemRepRepo.new
     dependency_tracker = Nanoc::Int::DependencyTracker.new(nil)
-    @view_context = Nanoc::ViewContext.new(reps: @reps, items: nil, dependency_tracker: dependency_tracker, compiler: :__irrelevant__)
+    @view_context = Nanoc::ViewContext.new(reps: @reps, items: nil, dependency_tracker: dependency_tracker, compilation_context: :__irrelevant__)
 
     @items = nil
     @item = nil
@@ -30,7 +32,7 @@ class Nanoc::Helpers::XMLSitemapTest < Nanoc::TestCase
       @items << item
 
       # Create item 3
-      attrs = { mtime: Time.parse('2004-07-12'), changefreq: 'daily', priority: 0.5 }
+      attrs = { mtime: Time.parse('2004-07-12 00:00:00 +02:00'), changefreq: 'daily', priority: 0.5 }
       item = Nanoc::ItemWithRepsView.new(Nanoc::Int::Item.new('some content 3', attrs, '/item-three/'), @view_context)
       @items << item
       create_item_rep(item.unwrap, :three_a, '/item-three/a/')
@@ -70,8 +72,8 @@ class Nanoc::Helpers::XMLSitemapTest < Nanoc::TestCase
       assert_equal '0.5',                              urls[3].css('> priority').inner_text
       assert_equal '',                                 urls[0].css('> lastmod').inner_text
       assert_equal '',                                 urls[1].css('> lastmod').inner_text
-      assert_equal '2004-07-12',                       urls[2].css('> lastmod').inner_text
-      assert_equal '2004-07-12',                       urls[3].css('> lastmod').inner_text
+      assert_equal '2004-07-11',                       urls[2].css('> lastmod').inner_text
+      assert_equal '2004-07-11',                       urls[3].css('> lastmod').inner_text
     end
   end
 
diff --git a/test/rule_dsl/test_action_provider.rb b/test/rule_dsl/test_action_provider.rb
index 5bcdff2..733f1d8 100644
--- a/test/rule_dsl/test_action_provider.rb
+++ b/test/rule_dsl/test_action_provider.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::RuleDSL::ActionProviderTest < Nanoc::TestCase
   def new_action_provider(site)
     rules_collection = Nanoc::RuleDSL::RulesCollection.new
diff --git a/test/rule_dsl/test_compiler_dsl.rb b/test/rule_dsl/test_compiler_dsl.rb
index 372e06e..07316b6 100644
--- a/test/rule_dsl/test_compiler_dsl.rb
+++ b/test/rule_dsl/test_compiler_dsl.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::RuleDSL::CompilerDSLTest < Nanoc::TestCase
   def test_compile
     # TODO: implement
@@ -290,7 +292,7 @@ EOS
   end
 
   def test_create_pattern_with_string_with_glob_string_pattern_type
-    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, { string_pattern_type: 'glob' })
+    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, string_pattern_type: 'glob')
 
     pattern = compiler_dsl.create_pattern('/foo/*')
     assert pattern.match?('/foo/aaaa')
@@ -299,14 +301,14 @@ EOS
   end
 
   def test_create_pattern_with_regex
-    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, { string_pattern_type: 'glob' })
+    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, string_pattern_type: 'glob')
 
     pattern = compiler_dsl.create_pattern(%r{\A/foo/a*/})
     assert pattern.match?('/foo/aaaa/')
   end
 
   def test_create_pattern_with_string_with_unknown_string_pattern_type
-    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, { string_pattern_type: 'donkey' })
+    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, string_pattern_type: 'donkey')
 
     err = assert_raises(Nanoc::Int::Errors::GenericTrivial) do
       compiler_dsl.create_pattern('/foo/*')
@@ -319,7 +321,7 @@ EOS
     compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, {})
 
     actual   = compiler_dsl.instance_eval { identifier_to_regex('foo') }
-    expected = %r{^/foo/$}
+    expected = %r{^/foo/?$}
 
     assert_equal(expected.to_s,      actual.to_s)
     assert_equal(expected.source,    actual.source)
@@ -333,7 +335,7 @@ EOS
     compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, {})
 
     actual   = compiler_dsl.instance_eval { identifier_to_regex('foo/*/bar') }
-    expected = %r{^/foo/(.*?)/bar/$}
+    expected = %r{^/foo/(.*?)/bar/?$}
 
     assert_equal(expected.to_s,      actual.to_s)
     assert_equal(expected.source,    actual.source)
@@ -347,7 +349,7 @@ EOS
     compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, {})
 
     actual   = compiler_dsl.instance_eval { identifier_to_regex('foo/*/bar/*/qux') }
-    expected = %r{^/foo/(.*?)/bar/(.*?)/qux/$}
+    expected = %r{^/foo/(.*?)/bar/(.*?)/qux/?$}
 
     assert_equal(expected.to_s,      actual.to_s)
     assert_equal(expected.source,    actual.source)
@@ -403,7 +405,7 @@ EOS
     compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, {})
 
     actual   = compiler_dsl.instance_eval { identifier_to_regex('/foo/+') }
-    expected = %r{^/foo/(.+?)/$}
+    expected = %r{^/foo/(.+?)/?$}
 
     assert_equal(expected.to_s,      actual.to_s)
     assert_equal(expected.source,    actual.source)
@@ -414,6 +416,20 @@ EOS
     refute('/foo/' =~ actual)
   end
 
+  def test_identifier_to_regex_with_full_identifier
+    # Create compiler DSL
+    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, {})
+
+    actual   = compiler_dsl.instance_eval { identifier_to_regex('/favicon.ico') }
+    expected = %r{^/favicon\.ico/?$}
+
+    assert_equal(expected.to_s, actual.to_s)
+
+    assert('/favicon.ico' =~ actual)
+    assert('/favicon.ico/' =~ actual)
+    refute('/faviconxico' =~ actual)
+  end
+
   def test_dsl_has_no_access_to_compiler
     compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, {})
     assert_raises(NameError) do
@@ -423,7 +439,7 @@ EOS
 
   def test_config
     $venetian = 'unsnares'
-    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, { venetian: 'snares' })
+    compiler_dsl = Nanoc::RuleDSL::CompilerDSL.new(nil, venetian: 'snares')
     compiler_dsl.instance_eval { $venetian = @config[:venetian] }
     assert_equal 'snares', $venetian
   end
diff --git a/test/rule_dsl/test_rule.rb b/test/rule_dsl/test_rule.rb
index 4405490..2541fb3 100644
--- a/test/rule_dsl/test_rule.rb
+++ b/test/rule_dsl/test_rule.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::Int::RuleTest < Nanoc::TestCase
   def test_initialize
     # TODO: implement
diff --git a/test/rule_dsl/test_rules_collection.rb b/test/rule_dsl/test_rules_collection.rb
index f885041..4512b34 100644
--- a/test/rule_dsl/test_rules_collection.rb
+++ b/test/rule_dsl/test_rules_collection.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::RuleDSL::RulesCollectionTest < Nanoc::TestCase
   def test_compilation_rule_for
     # Mock rules
diff --git a/test/test_gem.rb b/test/test_gem.rb
index 4ddad45..5f593d5 100644
--- a/test/test_gem.rb
+++ b/test/test_gem.rb
@@ -1,3 +1,5 @@
+require 'helper'
+
 class Nanoc::GemTest < Nanoc::TestCase
   def setup
     super

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-ruby-extras/nanoc.git



More information about the Pkg-ruby-extras-commits mailing list