summary history branches tags files
commit:570a357c51bb891c02d6138794e439329490aa0a
author:Trevor Bentley
committer:Trevor Bentley
date:Mon Jan 16 21:51:11 2023 +0100
parents:78156268bac0102b8da4c6a0c5aba8c9572764a0
major refactoring: remove 1-to-1 limit of template-to-output

Large refactoring that:
 * allow any template to be used any number of times
 * combine config's gitsy_templates and gitsy_outputs into gitsy_outputs
 * improves safety of output file names
 * prepares foundation for slugified permalinks
 * prepares foundation for parallelizing more execution
diff --git a/README.md b/README.md
line changes: +1/-1
index aef5252..fb659ce
--- a/README.md
+++ b/README.md
@@ -105,7 +105,7 @@ See the included `config.toml` for full documentation of the available settings.
 
 The top of the file contains global, site-wide settings like the site name, description, base URL, pagination rules, and memory limits.  Here you can also specify directories that contain Git repositories in subdirectories, which is used for bulk-import of many repositories.
 
-This should be followed by the `[gitsy_templates]` section and `[gitsy_outputs]` sections, which define the input templates and output paths respectively.  Input templates that are not specified will not be generated, so you can disable any output types that you don't need by commenting out the appropriate line in `[gitsy_templates]`
+This should be followed by the `[gitsy_outputs]` section, which defines which input templates to use, and which files to output.  Input templates that are not specified will not be generated, so you can disable any output types that you don't need.  Templates can be used as many times as desired, generating arbitrarily many outputs.
 
 An optional `[gitsy_extra]` section can be used to provide global, user-defined key/value pairs to all of the templates.  Use this if you want to add custom site-wide variables for use in your templates.
 

diff --git a/config.toml b/config.toml
line changes: +98/-151
index 2295dfe..c41ed60
--- a/config.toml
+++ b/config.toml
@@ -298,185 +298,129 @@ syntax_highlight_theme = "base16-ocean.light"
 
 ###############################################################################
 ##
-## Subsection specifying which files to use as templates.
+## Subsection specifying output paths, and how they are generated.
 ##
-## The individual templates are relative to the `path` directory.  Each
-## template is a single file using the Tera template engine's format for text
-## substitution.
+## Itsy-Gitsy requires two root paths, both specified here:
 ##
-## All except `path` are optional.  If not specified, the associated outputs
-## will not be generated.
+## 1) `output_root`, a directory where all rendered output will be written
+## 2) `template_root`, a directory where all input templates are stored
 ##
-###############################################################################
-[gitsy_templates]
-
-# Path to a folder containing Tera templates.
-#
-# All files with a .html extension found under this directory, and its
-# immediate children directories, are imported into the Tera template engine.
-path = "templates/default_light/"
-
-# Template responsible for the list of repositories.
-#
-# This template is evaluated with the list of all configured repositories.  It
-# is intended for providing an overview of the available repositories, but the
-# full details of each repository are also included.
-#
-# This template executes one time.
-repo_list    = "repos.html"
-
-# Template responsible for summarizing a single repository.
-#
-# This template is evaluated with a single parsed repository, with all repo
-# data (commits, branches, etc) available.
-#
-# This template executes one time per repository.
-summary = "summary.html"
-
-# Template responsible for displaying the commit history.
-#
-# This template is evaluated with the same data as `summary`.  If the
-# `paginate_history` setting is non-zero, this may be called several
-# times with the `history` template variable reduced to the requested
-# page size, and with a `page` template variable provided to identify
-# the current, previous, and next pages.
-#
-# This template executes at least one time per repository, or several
-# times if paginated.
-history      = "history.html"
-
-# Template responsible for displaying a single commit.
-#
-# Called once per parsed commit, with both the whole repository and the current
-# commit available to the template.
-#
-# This template executes many times.
-commit       = "commit.html"
-
-# Template responsible for displaying the repo branches.
-#
-# This template is evaluated with the same data as `summary`.  If the
-# `paginate_branches` setting is non-zero, this may be called several
-# times with the `branches` template variable reduced to the requested
-# page size, and with a `page` template variable provided to identify
-# the current, previous, and next pages.
-#
-# This template executes at least one time per repository, or several
-# times if paginated.
-branches     = "branches.html"
-
-# Template responsible for displaying a single branch.
-#
-# Called once per parsed branch, with both the whole repository and the current
-# branch available to the template.
-#
-# This template executes many times.
-branch       = "branch.html"
-
-# Template responsible for displaying the repo tags.
-#
-# This template is evaluated with the same data as `summary`.  If the
-# `paginate_tags` setting is non-zero, this may be called several
-# times with the `tags` template variable reduced to the requested
-# page size, and with a `page` template variable provided to identify
-# the current, previous, and next pages.
-#
-# This template executes at least one time per repository, or several
-# times if paginated.
-tags         = "tags.html"
-
-# Template responsible for displaying a single tag.
-#
-# Called once per parsed tag, with both the whole repository and the current
-# tag available to the template.
-#
-# This template executes many times.
-tag          = "tag.html"
-
-# Template responsible for displaying file tree.
-#
-# Called with the same variables as the `summary` page, this simply gives an
-# alternative locate to list the files in the root of the repository.
-#
-# This template executes once per repository.
-files        = "files.html"
-
-# Template responsible for displaying a single file.
-#
-# Called once per parsed file, with both the whole repository and the current
-# file available to the template.
-#
-# This template executes many times.
-file         = "file.html"
-
-# Template responsible for displaying a single directory.
-#
-# Called once per parsed directory, with both the whole repository and the
-# current directory available to the template.
-#
-# This template executes many times.
-dir          = "dir.html"
-
-# Template responsible for displaying a site-wide error.
-#
-# Intended for 404 (page not found) errors.  If used, you must configure your
-# webserver to redirect HTTP errors to the generated error page.
-#
-# This template executes one time.
-error        = "404.html"
-
-
-
-###############################################################################
 ##
-## Subsection specifying names of output files/directories.
+## Next, a `templates` table is defined, which contains a variable
+## number of entries.  Each entry must contain the following three
+## variables:
+##
+## - `template` -- the input file to use as a template, relative to
+##                 `template_root`
+##
+## - `output` -- the output file(s) to write, relative to
+##                 `output_root`.  These filenames can contain
+##                 variables, which are defined below.
+##
+## - `kind` -- the type of output being generated.  This decides which
+##             variables are available in the template, and which
+##             variables can be substituted in the output filename.
+##
+##
+## The following output types are available:
+##
+## - `repo_list` -- Template receives all defined repository metadata in the `repos` variable.
+##
+## - `summary` -- Template receives all metadata of the current repo.
+##                This is split across the `name`, `history`,
+##                `branches`, `tags`, `root_files`, `all_files`,
+##                `commits`, `file_ids`, commit_ids`, `metadata`,
+##                `last_ts_utc`, and `last_ts_offset` variables.
+##
+## - `history` -- All current repo metadata.  `history` variable not
+##                affected by `limit_context`.
 ##
-## Each entry pairs with one of the entries in `gitsy_templates` above,
-## specifying the directories and filenames for the rendered output, after the
-## template engine has performed its substitutions.
+## - `commit` -- All current repo metadata.  Current commit object in
+##               `commit` variable.
 ##
-## All outputs are relative to the `path` directory.
+## - `branches` -- All current repo metadata.  `branches` variable not
+##                 affected by `limit_context`.
 ##
-## There are currently two supported variables for paths:
+## - `branch` -- All current repo metadata.  Current branch object in
+##               `branch` variable.
 ##
-## * "%REPO%" -- replaced with the name of the currently processing repository
-## * "%ID%" -- replaced with the ID of the currently processing object
-## * "%PAGE%" -- replaced with the current page number if output is paginated
+## - `tags` -- All current repo metadata.  `tags` variable not
+##             affected by `limit_context`.
 ##
-## All except `path` are optional.  If not specified, sensible defaults will be
-## used.
+## - `tag` -- All current repo metadata.  Current tag object in `tag`
+##            variable.
+##
+## - `files` -- All current repo metadata.
+##
+## - `file` -- All current repo metadata.  Current file object in
+##             `file` variable.
+##
+## - `dir` -- All current repo metadata.  Current directory object in
+##            `dir` variable.
+##
+## - `error` -- All metadata for all repositories.
+##
+##
+## The following variables are permitted in `output` paths:
+##
+## - "%REPO%" -- Replaced with the name of the currently processing
+##               repository.  Available in all except `repos_list` and
+##               `error`.
+##
+## - "%ID%" -- Replaced with the ID of the currently processing
+##             object.  Available in `commit`, `branch`, `tag`,
+##             `file`, and `dir`.
+##
+## - "%PAGE%" -- Replaced with the current page number if output is
+##               paginated.  Available in `history`, `branches`, and
+##               `tags`.
+##
+##
+## All except `output_root` and `template_root` are optional.
+## Template types that are not specified will not be generated, and
+## all template types can be generated as many times as desired.
 ##
 ###############################################################################
 [gitsy_outputs]
-path = "rendered/"
-repo_list     = "index.html"
-summary  = "%REPO%/index.html"
-history       = "%REPO%/history%PAGE%.html"
-commit        = "%REPO%/commit/%ID%.html"
-branches      = "%REPO%/branches%PAGE%.html"
-branch        = "%REPO%/branch/%ID%.html"
-tags          = "%REPO%/tags%PAGE%.html"
-tag           = "%REPO%/tag/%ID%.html"
-files         = "%REPO%/files.html"
-file          = "%REPO%/file/%ID%.html"
-dir           = "%REPO%/dir/%ID%.html"
-error         = "404.html"
+output_root    = "rendered/"
+template_root  = "templates/default_light/"
+
+templates = [
+      { template = "repos.html",    output = "index.html",                 kind = "repo_list" },
+      { template = "summary.html",  output = "%REPO%/index.html",          kind = "summary"   },
+      { template = "history.html",  output = "%REPO%/history%PAGE%.html",  kind = "history"   },
+      { template = "commit.html",   output = "%REPO%/commit/%ID%.html",    kind = "commit"    },
+      { template = "branches.html", output = "%REPO%/branches%PAGE%.html", kind = "branches"  },
+      { template = "branch.html",   output = "%REPO%/branch/%ID%.html",    kind = "branch"    },
+      { template = "tags.html",     output = "%REPO%/tags%PAGE%.html",     kind = "tags"      },
+      { template = "tag.html",      output = "%REPO%/tag/%ID%.html",       kind = "tag"       },
+      { template = "files.html",    output = "%REPO%/files.html",          kind = "files"     },
+      { template = "file.html",     output = "%REPO%/file/%ID%.html",      kind = "file"      },
+      { template = "dir.html",      output = "%REPO%/dir/%ID%.html",       kind = "dir"       },
+      { template = "404.html",      output = "404.html",                   kind = "error"     }
+]
 
 # Output file for syntax highlighting CSS
 #
 # If syntax highlighting is enabled, a single CSS file will be
 # rendered to this path.  It must be included in the file template to
 # render the syntax highlighting correctly.
+#
+# If not specified, a default is used.
 syntax_css    = "%REPO%/file/syntax.css"
 
 # Output directory for files specified in global `asset_files`.
 #
 # Each input file is copied to this directory unmodified.
+#
+# If not specified, a default is used.
 global_assets = "assets/"
 
 # Output directory for files specified in per-repo `asset_files`.
 #
 # Each input file is copied to this directory unmodified.
+#
+# If not specified, a default is used.
 repo_assets   = "%REPO%/assets/"
 
 # Directory to clone remote repositories into.
@@ -487,9 +431,12 @@ repo_assets   = "%REPO%/assets/"
 # runs, all remote refs are fetched rather than recloning.
 #
 # Only non-authenticated HTTPS repositories are currently supported.
+#
+# If not specified, a default is used.
 cloned_repos  = "cloned_repos/"
 
 
+
 ###############################################################################
 ##
 ## Subsection for arbitrary, global user data.

diff --git a/src/generate.rs b/src/generate.rs
line changes: +573/-491
index 75461f4..a34f70e
--- a/src/generate.rs
+++ b/src/generate.rs
@@ -26,10 +26,11 @@ use crate::{
     loud, louder, loudest, normal, normal_noln,
     settings::{GitsyCli, GitsyRepoDescriptions, GitsySettings, GitsySettingsRepo},
     template::{DirFilter, FileFilter, HexFilter, MaskFilter, OctFilter, Pagination, TsDateFn, TsTimestampFn},
-    util::GitsyError,
+    util::{GitsyError, GitsyErrorKind, VERBOSITY},
 };
 use git2::{Error, Repository};
 use rayon::prelude::*;
+use chrono::{DateTime, Local};
 use std::cmp;
 use std::fs::File;
 use std::io::Write;
@@ -66,6 +67,9 @@ pub struct GitsyGenerator {
     cli: GitsyCli,
     settings: GitsySettings,
     repo_descriptions: GitsyRepoDescriptions,
+    tera: Option<Tera>,
+    total_bytes: AtomicUsize,
+    generated_dt: DateTime<Local>,
 }
 
 impl GitsyGenerator {
@@ -74,8 +78,126 @@ impl GitsyGenerator {
             cli,
             settings,
             repo_descriptions,
+            tera: None,
+            total_bytes: AtomicUsize::new(0),
+            generated_dt: chrono::offset::Local::now(),
         }
     }
+    fn new_context(&self, repo: Option<&GitRepo>) -> Result<Context, GitsyError> {
+        let mut ctx = match repo {
+            Some(repo) => Context::from_serialize(repo)?,
+            _ => Context::new(),
+        };
+        if let Some(extra) = &self.settings.extra {
+            ctx
+                .try_insert("extra", extra)
+                .expect("Failed to add extra settings to template engine.");
+        }
+        if let Some(site_name) = &self.settings.site_name {
+            ctx.insert("site_name", site_name);
+        }
+        if let Some(site_url) = &self.settings.site_url {
+            ctx.insert("site_url", site_url);
+        }
+        if let Some(site_description) = &self.settings.site_description {
+            ctx.insert("site_description", site_description);
+        }
+        ctx.insert("site_dir", &self.settings.outputs.output_dir());
+        if self.settings.outputs.global_assets.is_some() {
+            ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets::<GitFile>(None, None)));
+        }
+        ctx.insert("site_generated_ts", &self.generated_dt.timestamp());
+        ctx.insert("site_generated_offset", &self.generated_dt.offset().local_minus_utc());
+        Ok(ctx)
+    }
+
+    fn find_repo(&self, name: &str, repo_desc: &GitsySettingsRepo) -> Result<String, GitsyError> {
+        let repo_path = match &repo_desc.path {
+            url if url.starts_with("https://") || url.to_str().unwrap_or_default().contains("@") => {
+                if self.settings.outputs.cloned_repos.is_none() {
+                    return Err(GitsyError::kind(GitsyErrorKind::Settings,
+                                                Some(&format!("ERROR: Found remote repo [{}], but `cloned_repos` directory not configured.", name))));
+                };
+                let clone_path: PathBuf = [self.settings.outputs.cloned_repos.as_deref().unwrap(), name]
+                    .iter()
+                    .collect();
+                match Repository::open(&clone_path) {
+                    Ok(r) => {
+                        // Repo already cloned, so update all refs
+                        let refs: Vec<String> = r
+                            .references()
+                            .expect(&format!("Unable to enumerate references for repo [{}]", name))
+                            .map(|x| {
+                                x.expect(&format!("Found invalid reference in repo [{}]", name))
+                                    .name()
+                                    .expect(&format!("Found unnamed reference in repo: [{}]", name))
+                                    .to_string()
+                            })
+                            .collect();
+                        r.find_remote("origin")
+                            .expect(&format!("Clone of repo [{}] missing `origin` remote.", name))
+                            .fetch(&refs, None, None)
+                            .expect(&format!("Failed to fetch updates from remote repo [{}]", name));
+                        clone_path.to_string_lossy().to_string()
+                    }
+                    Err(_) => {
+                        let mut builder = git2::build::RepoBuilder::new();
+
+                        // TODO: git2-rs's ssh support just doesn't seem to
+                        // work.  It finds the repo, but fails to either
+                        // decrypt or use the private key.
+                        //
+                        //if !url.starts_with("https://") {
+                        //    use secrecy::ExposeSecret;
+                        //    // this must be SSH, which needs credentials.
+                        //    let mut callbacks = git2::RemoteCallbacks::new();
+                        //    callbacks.credentials(|_url, username_from_url, _allowed_types| {
+                        //        //git2::Cred::ssh_key_from_agent(username_from_url.unwrap())
+                        //
+                        //        let keyfile = format!("{}/.ssh/id_rsa", std::env::var("HOME").unwrap());
+                        //        let passphrase = pinentry::PassphraseInput::with_default_binary().unwrap()
+                        //            .with_description(&format!("Enter passphrase for SSH key {} (repo: {})",
+                        //                                       keyfile, url.display()))
+                        //            .with_prompt("Passphrase:")
+                        //            .interact().unwrap();
+                        //        git2::Cred::ssh_key(
+                        //            username_from_url.unwrap(),
+                        //            None,
+                        //            Path::new(&keyfile),
+                        //            Some(passphrase.expose_secret()),
+                        //        )
+                        //    });
+                        //    let mut options = git2::FetchOptions::new();
+                        //    options.remote_callbacks(callbacks);
+                        //    builder.fetch_options(options);
+                        //}
+                        builder
+                            .bare(true)
+                            .clone(&url.to_string_lossy().to_string(), &clone_path)
+                            .expect(&format!("Failed to clone remote repo [{}]", name));
+                        clone_path.to_string_lossy().to_string()
+                    }
+                }
+            }
+            dir => {
+                match dir.metadata() {
+                    Ok(m) if m.is_dir() => {}
+                    _ => {
+                        error!(
+                            "ERROR: local repository [{}]: directory not found: {}",
+                            name,
+                            dir.display()
+                        );
+                        return Err(GitsyError::kind(GitsyErrorKind::Settings,
+                                                    Some(&format!("ERROR: Local repository not found: {}", name))));
+                    }
+                }
+                dir.to_string_lossy().to_string()
+            }
+        };
+        Ok(repo_path)
+    }
+
     #[cfg(feature = "markdown")]
     fn parse_markdown(contents: &str) -> String {
         let mut options = Options::empty();
@@ -146,48 +268,19 @@ impl GitsyGenerator {
     }
 
     fn write_rendered<P: AsRef<Path>>(&self, path: &P, rendered: &str) -> usize {
-        let path = path.as_ref().to_str()
-            .expect(&format!("ERROR: attempted to write unrecognizeable path: {}", path.as_ref().display()));
-        // Ensure that the requested output path is actually a child
-        // of the output directory, as a sanity check to ensure we
-        // aren't writing out of bounds.
-        let canonical_root = self.settings.outputs.path.canonicalize().expect(&format!(
-            "Cannot find canonical version of output path: {}",
-            self.settings.outputs.path.display()
-        ));
-        let canonical_path = PathBuf::from(path);
-        let has_relative_dirs = canonical_path
-            .ancestors()
-            .any(|x| x.file_name().is_none() && x != Path::new("/"));
-        assert!(
-            canonical_path.is_absolute(),
-            "ERROR: write_rendered called with a relative path: {}",
-            path
-        );
-        assert!(
-            !has_relative_dirs,
-            "ERROR: write_rendered called with a relative path: {}",
-            path
-        );
-        let _ = canonical_path
-            .ancestors()
-            .find(|x| x == &canonical_root)
-            .expect(&format!(
-                "Output file {} not contained in output path: {}",
-                canonical_path.display(),
-                canonical_root.display()
-            ));
-
+        let path: &Path = path.as_ref();
+        assert!(self.settings.outputs.assert_valid(&path),
+                "ERROR: attempted to write invalid path: {}", path.display());
         // Write the file to disk
-        let mut file = File::create(path).expect(&format!("Unable to write to output path: {}", path));
+        let mut file = File::create(path).expect(&format!("Unable to write to output path: {}", path.display()));
         file.write(rendered.as_bytes())
-            .expect(&format!("Failed to save rendered html to path: {}", path));
-        louder!(" - wrote file: {}", path);
+            .expect(&format!("Failed to save rendered html to path: {}", path.display()));
+        louder!(" - wrote file: {}", path.display());
         rendered.as_bytes().len()
     }
 
     fn tera_init(&self) -> Result<Tera, GitsyError> {
-        let mut template_path = self.settings.templates.path.clone();
+        let mut template_path = self.settings.outputs.template_dir();
         template_path.push("**");
         template_path.push("*.html");
         let mut tera = Tera::new(&template_path.to_string_lossy().to_string())?;
@@ -201,23 +294,426 @@ impl GitsyGenerator {
         Ok(tera)
     }
 
-    pub fn generate(&self) -> Result<(), GitsyError> {
-        let start_all = Instant::now();
-        let tera = self.tera_init()?;
-
-        // Create output directory
-        if self.cli.should_clean {
-            louder!("Cleaning output directory: {}", self.settings.outputs.path.display());
-            self.settings.outputs.clean();
+    pub fn gen_repo_list(&self, ctx: &Context) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut global_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.repo_list::<GitRepo>(None, None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            match tera.render(templ_path, &ctx) {
+                Ok(rendered) => {
+                    global_bytes += self.write_rendered(&out_path, &rendered);
+                }
+                Err(x) => match x.kind {
+                    _ => error!("ERROR: {:?}", x),
+                },
+            }
         }
-        louder!("Creating output directory: {}", self.settings.outputs.path.display());
-        self.settings.outputs.create();
+        Ok(global_bytes)
+    }
 
-        let generated_dt = chrono::offset::Local::now();
+    pub fn gen_error(&self, ctx: &Context) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
         let mut global_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.error::<GitRepo>(None, None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            match tera.render(templ_path, &ctx) {
+                Ok(rendered) => {
+                    global_bytes += self.write_rendered(&out_path, &rendered);
+                }
+                Err(x) => match x.kind {
+                    _ => error!("ERROR: {:?}", x),
+                },
+            }
+        }
+        Ok(global_bytes)
+    }
+
+    pub fn gen_summary(&self, ctx: &Context, parsed_repo: &GitRepo, _repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.summary::<GitRepo>(Some(parsed_repo), None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            match tera.render(templ_path, &ctx) {
+                Ok(rendered) => {
+                    repo_bytes +=
+                        self.write_rendered(&out_path, &rendered);
+                }
+                Err(x) => match x.kind {
+                    _ => error!("ERROR: {:?}", x),
+                },
+            }
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_history(&self, ctx: &Context, parsed_repo: &GitRepo, _repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.history::<GitRepo>(Some(parsed_repo), None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            let mut paged_ctx = ctx.clone();
+            paged_ctx.remove("history");
+            let pages = parsed_repo.history.chunks(self.settings.paginate_history());
+            let page_count = pages.len();
+            for (idx, page) in pages.enumerate() {
+                let pagination = Pagination::new(
+                    idx + 1,
+                    page_count,
+                    &out_path,
+                );
+                paged_ctx.insert("page", &pagination.with_relative_paths());
+                paged_ctx.insert("history", &page);
+                match tera.render(templ_path, &paged_ctx) {
+                    Ok(rendered) => {
+                        repo_bytes += self.write_rendered(&pagination.cur_page, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+                paged_ctx.remove("page");
+                paged_ctx.remove("history");
+            }
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_commit(&self, ctx: &Context, parsed_repo: &GitRepo, repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let mut ctx = ctx.clone();
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for (_id, commit) in &parsed_repo.commits {
+            size_check!(repo_desc, repo_bytes, self.total_bytes.load(Ordering::Relaxed), break);
+            ctx
+                .try_insert("commit", &commit)
+                .expect("Failed to add commit to template engine.");
+            for (templ_path, out_path) in self.settings.outputs.commit(Some(parsed_repo), Some(commit)) {
+                let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+                let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+                match tera.render(templ_path, &ctx) {
+                    Ok(rendered) => {
+                        repo_bytes += self
+                            .write_rendered(&out_path, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+            }
+            ctx.remove("commit");
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_branches(&self, ctx: &Context, parsed_repo: &GitRepo, _repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.branches::<GitRepo>(Some(parsed_repo), None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            let mut paged_ctx = ctx.clone();
+            paged_ctx.remove("branches");
+            let pages = parsed_repo.branches.chunks(self.settings.paginate_branches());
+            let page_count = pages.len();
+            for (idx, page) in pages.enumerate() {
+                let pagination = Pagination::new(
+                    idx + 1,
+                    page_count,
+                    &out_path,
+                );
+                paged_ctx.insert("page", &pagination.with_relative_paths());
+                paged_ctx.insert("branches", &page);
+                match tera.render(templ_path, &paged_ctx) {
+                    Ok(rendered) => {
+                        repo_bytes += self.write_rendered(&pagination.cur_page, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+                paged_ctx.remove("page");
+                paged_ctx.remove("branches");
+            }
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_branch(&self, ctx: &Context, parsed_repo: &GitRepo, repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let mut ctx = ctx.clone();
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for branch in &parsed_repo.branches {
+            size_check!(repo_desc, repo_bytes, self.total_bytes.load(Ordering::Relaxed), break);
+            ctx.insert("branch", branch);
+            for (templ_path, out_path) in self.settings.outputs.branch(Some(parsed_repo), Some(branch)) {
+                let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+                let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+                match tera.render(templ_path, &ctx) {
+                    Ok(rendered) => {
+                        repo_bytes += self
+                            .write_rendered(&out_path, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+            }
+            ctx.remove("branch");
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_tags(&self, ctx: &Context, parsed_repo: &GitRepo, _repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.tags::<GitRepo>(Some(parsed_repo), None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            let mut paged_ctx = ctx.clone();
+            paged_ctx.remove("tags");
+            let pages = parsed_repo.tags.chunks(self.settings.paginate_tags());
+            let page_count = pages.len();
+            for (idx, page) in pages.enumerate() {
+                let pagination =
+                    Pagination::new(idx + 1, page_count, &out_path);
+                paged_ctx.insert("page", &pagination.with_relative_paths());
+                paged_ctx.insert("tags", &page);
+                match tera.render(templ_path, &paged_ctx) {
+                    Ok(rendered) => {
+                        repo_bytes += self.write_rendered(&pagination.cur_page, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+                paged_ctx.remove("page");
+                paged_ctx.remove("tags");
+            }
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_tag(&self, ctx: &Context, parsed_repo: &GitRepo, repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let mut ctx = ctx.clone();
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for tag in &parsed_repo.tags {
+            size_check!(repo_desc, repo_bytes, self.total_bytes.load(Ordering::Relaxed), break);
+            ctx.insert("tag", tag);
+            if let Some(tagged_id) = tag.tagged_id.as_ref() {
+                if let Some(commit) = parsed_repo.commits.get(tagged_id) {
+                    ctx.insert("commit", &commit);
+                }
+            }
+            for (templ_path, out_path) in self.settings.outputs.tag(Some(parsed_repo), Some(tag)) {
+                let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+                let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+                match tera.render(templ_path, &ctx) {
+                    Ok(rendered) => {
+                        repo_bytes +=
+                            self.write_rendered(&out_path, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+            }
+            ctx.remove("tag");
+            ctx.remove("commit");
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_files(&self, ctx: &Context, parsed_repo: &GitRepo, _repo_desc: &GitsySettingsRepo, _repo: &Repository) -> Result<usize, GitsyError> {
+        let mut ctx = ctx.clone();
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for (templ_path, out_path) in self.settings.outputs.files::<GitRepo>(Some(parsed_repo), None) {
+            let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+            let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+            ctx.insert("root_files", &parsed_repo.root_files);
+            ctx.insert("all_files", &parsed_repo.all_files);
+            match tera.render(templ_path, &ctx) {
+                Ok(rendered) => {
+                    repo_bytes +=
+                        self.write_rendered(&out_path, &rendered);
+                }
+                Err(x) => match x.kind {
+                    _ => error!("ERROR: {:?}", x),
+                },
+            }
+        }
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_file(&self, ctx: &Context, parsed_repo: &GitRepo, repo_desc: &GitsySettingsRepo, repo: &Repository) -> Result<usize, GitsyError> {
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+
+        #[cfg(any(feature = "highlight", feature = "highlight_fast"))]
+        if self.settings.outputs.has_files() {
+            let ts = ThemeSet::load_defaults();
+            let theme = ts
+                .themes
+                .get(
+                    repo_desc
+                        .syntax_highlight_theme
+                        .as_deref()
+                        .unwrap_or("base16-ocean.dark"),
+                )
+                .expect("Invalid syntax highlighting theme specified.");
+            let css: String = css_for_theme_with_class_style(theme, syntect::html::ClassStyle::Spaced)
+                .expect("Invalid syntax highlighting theme specified.");
+            repo_bytes +=
+                self.write_rendered(&self.settings.outputs.syntax_css::<GitFile>(Some(&parsed_repo), None), css.as_str());
+        }
+
+        // TODO: parallelize the rest of the processing steps.  This one is
+        // done first because syntax highlighting is very slow.
+        let files: Vec<&GitFile> = parsed_repo.all_files.iter().filter(|x| x.kind == "file").collect();
+        let atomic_bytes: AtomicUsize = AtomicUsize::new(repo_bytes);
+        let repo_path = repo.path().to_str().expect("ERROR: unable to determine path to local repository");
+        let _ = files
+            .par_iter()
+            .fold(
+                || Some(0),
+                |acc, file| {
+                    // These two have to be recreated.  Cloning the Tera context is expensive.
+                    let repo = Repository::open(&repo_path).expect("Unable to find git repository.");
+                    let mut ctx = ctx.clone();
+
+                    let mut local_bytes = 0;
+                    let cur_repo_bytes = atomic_bytes.load(Ordering::Relaxed);
+                    size_check!(repo_desc, cur_repo_bytes, self.total_bytes.load(Ordering::Relaxed), return None);
+                    let file = match file.size < repo_desc.limit_file_size.unwrap_or(usize::MAX) {
+                        true => GitsyGenerator::fill_file_contents(&repo, &file, &repo_desc)
+                            .expect("Failed to parse file."),
+                        false => (*file).clone(),
+                    };
+                    ctx
+                        .try_insert("file", &file)
+                        .expect("Failed to add file to template engine.");
+                    for (templ_path, out_path) in self.settings.outputs.file(Some(parsed_repo), Some(&file)) {
+                        let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+                        let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+                        match tera.render(templ_path, &ctx) {
+                            Ok(rendered) => {
+                                local_bytes = self.write_rendered(&out_path, &rendered,);
+                                atomic_bytes.fetch_add(local_bytes, Ordering::Relaxed);
+                            }
+                            Err(x) => match x.kind {
+                                _ => error!("ERROR: {:?}", x),
+                            },
+                        }
+                    }
+                    ctx.remove("file");
+                    Some(acc.unwrap() + local_bytes)
+                },
+            )
+            .while_some() // allow short-circuiting if size limit is reached
+            .sum::<usize>();
+        repo_bytes = atomic_bytes.load(Ordering::Relaxed);
+        Ok(repo_bytes)
+    }
+
+    pub fn gen_dir(&self, ctx: &Context, parsed_repo: &GitRepo, repo_desc: &GitsySettingsRepo, repo: &Repository) -> Result<usize, GitsyError> {
+        let mut ctx = ctx.clone();
+        let tera = self.tera.as_ref().expect("ERROR: generate called without a context!?");
+        let mut repo_bytes = 0;
+        for dir in parsed_repo.all_files.iter().filter(|x| x.kind == "dir") {
+            size_check!(repo_desc, repo_bytes, self.total_bytes.load(Ordering::Relaxed), break);
+            let listing = dir_listing(&repo, &dir).expect("Failed to parse file.");
+            ctx.insert("dir", dir);
+            ctx
+                .try_insert("files", &listing)
+                .expect("Failed to add dir to template engine.");
+            for (templ_path, out_path) in self.settings.outputs.dir(Some(parsed_repo), Some(dir)) {
+                let templ_path = templ_path.to_str().expect(&format!("ERROR: a summary template path is invalid: {}", templ_path.display()));
+                let out_path = out_path.to_str().expect(&format!("ERROR: a summary output path is invalid: {}", out_path.display()));
+                match tera.render(templ_path, &ctx) {
+                    Ok(rendered) => {
+                        repo_bytes +=
+                            self.write_rendered(&out_path, &rendered);
+                    }
+                    Err(x) => match x.kind {
+                        _ => error!("ERROR: {:?}", x),
+                    },
+                }
+            }
+            ctx.remove("files");
+            ctx.remove("dir");
+        }
+        Ok(repo_bytes)
+    }
+
+    fn copy_assets(&self, repo_desc: Option<&GitsySettingsRepo>, parsed_repo: Option<&GitRepo>, repo: Option<&Repository>) -> Result<usize, GitsyError> {
+        let mut bytes = 0;
+        match repo_desc {
+            Some(repo_desc) => {
+                let parsed_repo = parsed_repo.expect("ERROR: attempted to fill repo assets without a repository");
+                let repo = repo.expect("ERROR: attempted to fill repo assets without a repository");
+                //let repo_path = repo.path().to_str().expect("ERROR: repository has no path!");
+                if repo_desc.asset_files.is_some() {
+                    let target_dir = self.settings.outputs.repo_assets::<GitFile>(Some(&parsed_repo), None);
+                    for src_file in repo_desc.asset_files.as_ref().unwrap() {
+                        let src_file = self.settings.outputs.asset(src_file, Some(parsed_repo), Some(repo));
+                        let mut dst_file = PathBuf::from(&target_dir);
+                        dst_file.push(src_file.file_name().expect(&format!(
+                            "Failed to copy repo asset file: {} ({})",
+                            src_file.display(),
+                            repo_desc.name.as_deref().unwrap_or_default()
+                        )));
+                        std::fs::copy(&src_file, &dst_file).expect(&format!(
+                            "Failed to copy repo asset file: {} ({})",
+                            src_file.display(),
+                            repo_desc.name.as_deref().unwrap_or_default()
+                        ));
+                        if let Ok(meta) = std::fs::metadata(dst_file) {
+                            bytes += meta.len() as usize;
+                        }
+                        loud!(" - copied asset: {}", src_file.display());
+                    }
+                }
+            },
+            _ => {
+                if self.settings.asset_files.is_some() {
+                    let target_dir = self.settings.outputs.global_assets::<GitFile>(None, None);
+                    for src_file in self.settings.asset_files.as_ref().unwrap() {
+                        let src_file = self.settings.outputs.asset(src_file, None, None);
+                        let mut dst_file = PathBuf::from(&target_dir);
+                        dst_file.push(
+                            src_file
+                                .file_name()
+                                .expect(&format!("Failed to copy asset file: {}", src_file.display())),
+                        );
+                        std::fs::copy(&src_file, &dst_file)
+                            .expect(&format!("Failed to copy asset file: {}", src_file.display()));
+                        if let Ok(meta) = std::fs::metadata(dst_file) {
+                            bytes += meta.len() as usize;
+                        }
+                        loud!(" - copied asset: {}", src_file.display());
+                    }
+                }
+            },
+        }
+        Ok(bytes)
+    }
+
+    pub fn generate(&mut self) -> Result<(), GitsyError> {
+        let start_all = Instant::now();
+        self.tera = Some(self.tera_init()?);
+        self.generated_dt = chrono::offset::Local::now();
         let mut total_bytes = 0;
         let mut repos: Vec<GitRepo> = vec![];
 
+        if self.cli.should_clean {
+            self.settings.outputs.clean();
+        }
+
         if self.repo_descriptions.len() == 0 {
             panic!(
                 "No Git repositories defined!  Please check your configuration file ({})",
@@ -225,6 +721,8 @@ impl GitsyGenerator {
             );
         }
 
+        self.settings.outputs.create();
+
         // Sort the repositories by name
         let mut repo_vec: Vec<GitsySettingsRepo> = self.repo_descriptions.iter().cloned().collect();
         repo_vec.sort_by(|x, y| {
@@ -233,6 +731,7 @@ impl GitsyGenerator {
                 .map(|n| n.cmp(&y.name.as_deref().unwrap_or_default()))
                 .unwrap_or(cmp::Ordering::Equal)
         });
+
         // Find the one with the longest name, for pretty printing
         let global_name = "repo list";
         let longest_repo_name = repo_vec
@@ -249,96 +748,12 @@ impl GitsyGenerator {
             loudest!("Repo settings:\n{:#?}", &repo_desc);
             let start_repo = Instant::now();
             let mut repo_bytes = 0;
+
             let name = repo_desc.name.as_deref().expect("A configured repository has no name!");
             normal_noln!("[{}{}]... ", name, " ".repeat(longest_repo_name - name.len()));
-
-            let repo_path = match &repo_desc.path {
-                url if url.starts_with("https://") || url.to_str().unwrap_or_default().contains("@") => {
-                    if self.settings.outputs.cloned_repos.is_none() {
-                        error!(
-                            "ERROR: Found remote repo [{}], but `cloned_repos` directory not configured.",
-                            name
-                        );
-                        continue;
-                    };
-                    let clone_path: PathBuf = [self.settings.outputs.cloned_repos.as_deref().unwrap(), name]
-                        .iter()
-                        .collect();
-                    match Repository::open(&clone_path) {
-                        Ok(r) => {
-                            // Repo already cloned, so update all refs
-                            let refs: Vec<String> = r
-                                .references()
-                                .expect(&format!("Unable to enumerate references for repo [{}]", name))
-                                .map(|x| {
-                                    x.expect(&format!("Found invalid reference in repo [{}]", name))
-                                        .name()
-                                        .expect(&format!("Found unnamed reference in repo: [{}]", name))
-                                        .to_string()
-                                })
-                                .collect();
-                            r.find_remote("origin")
-                                .expect(&format!("Clone of repo [{}] missing `origin` remote.", name))
-                                .fetch(&refs, None, None)
-                                .expect(&format!("Failed to fetch updates from remote repo [{}]", name));
-                            clone_path.to_string_lossy().to_string()
-                        }
-                        Err(_) => {
-                            let mut builder = git2::build::RepoBuilder::new();
-
-                            // TODO: git2-rs's ssh support just doesn't seem to
-                            // work.  It finds the repo, but fails to either
-                            // decrypt or use the private key.
-                            //
-                            //if !url.starts_with("https://") {
-                            //    use secrecy::ExposeSecret;
-                            //    // this must be SSH, which needs credentials.
-                            //    let mut callbacks = git2::RemoteCallbacks::new();
-                            //    callbacks.credentials(|_url, username_from_url, _allowed_types| {
-                            //        //git2::Cred::ssh_key_from_agent(username_from_url.unwrap())
-                            //
-                            //        let keyfile = format!("{}/.ssh/id_rsa", std::env::var("HOME").unwrap());
-                            //        let passphrase = pinentry::PassphraseInput::with_default_binary().unwrap()
-                            //            .with_description(&format!("Enter passphrase for SSH key {} (repo: {})",
-                            //                                       keyfile, url.display()))
-                            //            .with_prompt("Passphrase:")
-                            //            .interact().unwrap();
-                            //        git2::Cred::ssh_key(
-                            //            username_from_url.unwrap(),
-                            //            None,
-                            //            Path::new(&keyfile),
-                            //            Some(passphrase.expose_secret()),
-                            //        )
-                            //    });
-                            //    let mut options = git2::FetchOptions::new();
-                            //    options.remote_callbacks(callbacks);
-                            //    builder.fetch_options(options);
-                            //}
-                            builder
-                                .bare(true)
-                                .clone(&url.to_string_lossy().to_string(), &clone_path)
-                                .expect(&format!("Failed to clone remote repo [{}]", name));
-                            clone_path.to_string_lossy().to_string()
-                        }
-                    }
-                }
-                dir => {
-                    match dir.metadata() {
-                        Ok(m) if m.is_dir() => {}
-                        _ => {
-                            error!(
-                                "ERROR: local repository [{}]: directory not found: {}",
-                                name,
-                                dir.display()
-                            );
-                            continue;
-                        }
-                    }
-                    dir.to_string_lossy().to_string()
-                }
-            };
-
+            let repo_path = self.find_repo(&name, &repo_desc)?;
             let repo = Repository::open(&repo_path).expect("Unable to find git repository.");
+
             let metadata = GitsyMetadata {
                 full_name: repo_desc.name.clone(),
                 description: repo_desc.description.clone(),
@@ -349,28 +764,9 @@ impl GitsyGenerator {
             let parsed_repo = parse_repo(&repo, &name, &repo_desc, metadata).expect("Failed to analyze repo HEAD.");
             let minimized_repo = parsed_repo.minimal_clone(self.settings.limit_context.unwrap_or(usize::MAX));
 
-            let mut local_ctx = Context::from_serialize(&minimized_repo).unwrap();
-            if let Some(extra) = &self.settings.extra {
-                local_ctx
-                    .try_insert("extra", extra)
-                    .expect("Failed to add extra settings to template engine.");
-            }
-            if let Some(site_name) = &self.settings.site_name {
-                local_ctx.insert("site_name", site_name);
-            }
-            if let Some(site_url) = &self.settings.site_url {
-                local_ctx.insert("site_url", site_url);
-            }
-            if let Some(site_description) = &self.settings.site_description {
-                local_ctx.insert("site_description", site_description);
-            }
-            local_ctx.insert("site_dir", &self.settings.outputs.output_dir());
-            if self.settings.outputs.global_assets.is_some() {
-                local_ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets::<GitFile>(None, None)));
-            }
-            local_ctx.insert("site_generated_ts", &generated_dt.timestamp());
-            local_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc());
+            let mut local_ctx = self.new_context(Some(&minimized_repo))?;
 
+            // Add README file to context, if specified and found
             if let Some(readmes) = &repo_desc.readme_files {
                 for readme in readmes {
                     if let Some(file) = parsed_repo.root_files.iter().filter(|x| &x.name == readme).next() {
@@ -383,281 +779,23 @@ impl GitsyGenerator {
                 }
             };
 
-            if let Some(templ_file) = self.settings.templates.summary.as_deref() {
-                match tera.render(templ_file, &local_ctx) {
-                    Ok(rendered) => {
-                        repo_bytes +=
-                            self.write_rendered(&self.settings.outputs.summary::<GitFile>(Some(&parsed_repo), None), &rendered);
-                    }
-                    Err(x) => match x.kind {
-                        _ => error!("ERROR: {:?}", x),
-                    },
-                }
-            }
-
-            if let Some(templ_file) = self.settings.templates.branches.as_deref() {
-                let mut paged_ctx = local_ctx.clone();
-                paged_ctx.remove("branches");
-                let pages = parsed_repo.branches.chunks(self.settings.paginate_branches());
-                let page_count = pages.len();
-                for (idx, page) in pages.enumerate() {
-                    let pagination = Pagination::new(
-                        idx + 1,
-                        page_count,
-                        &self.settings.outputs.branches::<GitFile>(Some(&parsed_repo), None),
-                    );
-                    paged_ctx.insert("page", &pagination.with_relative_paths());
-                    paged_ctx.insert("branches", &page);
-                    match tera.render(templ_file, &paged_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes += self.write_rendered(&pagination.cur_page, &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                    paged_ctx.remove("page");
-                    paged_ctx.remove("branches");
-                }
-            }
-
-            for branch in &parsed_repo.branches {
-                size_check!(repo_desc, repo_bytes, total_bytes, break);
-                local_ctx.insert("branch", branch);
-                if let Some(templ_file) = self.settings.templates.branch.as_deref() {
-                    match tera.render(templ_file, &local_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes += self
-                                .write_rendered(&self.settings.outputs.branch(Some(&parsed_repo), Some(branch)), &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                }
-                local_ctx.remove("branch");
-            }
-
-            if let Some(templ_file) = self.settings.templates.tags.as_deref() {
-                let mut paged_ctx = local_ctx.clone();
-                paged_ctx.remove("tags");
-                let pages = parsed_repo.tags.chunks(self.settings.paginate_tags());
-                let page_count = pages.len();
-                for (idx, page) in pages.enumerate() {
-                    let pagination =
-                        Pagination::new(idx + 1, page_count, &self.settings.outputs.tags::<GitFile>(Some(&parsed_repo), None));
-                    paged_ctx.insert("page", &pagination.with_relative_paths());
-                    paged_ctx.insert("tags", &page);
-                    match tera.render(templ_file, &paged_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes += self.write_rendered(&pagination.cur_page, &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                    paged_ctx.remove("page");
-                    paged_ctx.remove("tags");
-                }
-            }
-
-            for tag in &parsed_repo.tags {
-                size_check!(repo_desc, repo_bytes, total_bytes, break);
-                local_ctx.insert("tag", tag);
-                if let Some(tagged_id) = tag.tagged_id.as_ref() {
-                    if let Some(commit) = parsed_repo.commits.get(tagged_id) {
-                        local_ctx.insert("commit", &commit);
-                    }
-                }
-                if let Some(templ_file) = self.settings.templates.tag.as_deref() {
-                    match tera.render(templ_file, &local_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes +=
-                                self.write_rendered(&self.settings.outputs.tag(Some(&parsed_repo), Some(tag)), &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                }
-                local_ctx.remove("tag");
-                local_ctx.remove("commit");
-            }
-
-            if let Some(templ_file) = self.settings.templates.history.as_deref() {
-                let mut paged_ctx = local_ctx.clone();
-                paged_ctx.remove("history");
-                let pages = parsed_repo.history.chunks(self.settings.paginate_history());
-                let page_count = pages.len();
-                for (idx, page) in pages.enumerate() {
-                    let pagination = Pagination::new(
-                        idx + 1,
-                        page_count,
-                        &self.settings.outputs.history::<GitFile>(Some(&parsed_repo), None),
-                    );
-                    paged_ctx.insert("page", &pagination.with_relative_paths());
-                    paged_ctx.insert("history", &page);
-                    match tera.render(templ_file, &paged_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes += self.write_rendered(&pagination.cur_page, &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                    paged_ctx.remove("page");
-                    paged_ctx.remove("history");
-                }
-            }
-
-            for (_id, commit) in &parsed_repo.commits {
-                size_check!(repo_desc, repo_bytes, total_bytes, break);
-                local_ctx
-                    .try_insert("commit", &commit)
-                    .expect("Failed to add commit to template engine.");
-                if let Some(templ_file) = self.settings.templates.commit.as_deref() {
-                    match tera.render(templ_file, &local_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes += self
-                                .write_rendered(&self.settings.outputs.commit(Some(&parsed_repo), Some(commit)), &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                }
-                local_ctx.remove("commit");
-            }
-
-            #[cfg(any(feature = "highlight", feature = "highlight_fast"))]
-            if self.settings.templates.file.is_some() {
-                let ts = ThemeSet::load_defaults();
-                let theme = ts
-                    .themes
-                    .get(
-                        repo_desc
-                            .syntax_highlight_theme
-                            .as_deref()
-                            .unwrap_or("base16-ocean.dark"),
-                    )
-                    .expect("Invalid syntax highlighting theme specified.");
-                let css: String = css_for_theme_with_class_style(theme, syntect::html::ClassStyle::Spaced)
-                    .expect("Invalid syntax highlighting theme specified.");
-                repo_bytes +=
-                    self.write_rendered(&self.settings.outputs.syntax_css::<GitFile>(Some(&parsed_repo), None), css.as_str());
-            }
-
-            // TODO: parallelize the rest of the processing steps.  This one is
-            // done first because syntax highlighting is very slow.
-            let files: Vec<&GitFile> = parsed_repo.all_files.iter().filter(|x| x.kind == "file").collect();
-            let atomic_bytes: AtomicUsize = AtomicUsize::new(repo_bytes);
-            let _ = files
-                .par_iter()
-                .fold(
-                    || Some(0),
-                    |acc, file| {
-                        // These two have to be recreated.  Cloning the Tera context is expensive.
-                        let repo = Repository::open(&repo_path).expect("Unable to find git repository.");
-                        let mut local_ctx = local_ctx.clone();
-
-                        let mut local_bytes = 0;
-                        let cur_repo_bytes = atomic_bytes.load(Ordering::Relaxed);
-                        size_check!(repo_desc, cur_repo_bytes, total_bytes, return None);
-                        let file = match file.size < repo_desc.limit_file_size.unwrap_or(usize::MAX) {
-                            true => GitsyGenerator::fill_file_contents(&repo, &file, &repo_desc)
-                                .expect("Failed to parse file."),
-                            false => (*file).clone(),
-                        };
-                        local_ctx
-                            .try_insert("file", &file)
-                            .expect("Failed to add file to template engine.");
-                        if let Some(templ_file) = self.settings.templates.file.as_deref() {
-                            match tera.render(templ_file, &local_ctx) {
-                                Ok(rendered) => {
-                                    local_bytes = self.write_rendered(
-                                        &self.settings.outputs.file(Some(&parsed_repo), Some(&file)),
-                                        &rendered,
-                                    );
-                                    atomic_bytes.fetch_add(local_bytes, Ordering::Relaxed);
-                                }
-                                Err(x) => match x.kind {
-                                    _ => error!("ERROR: {:?}", x),
-                                },
-                            }
-                        }
-                        local_ctx.remove("file");
-                        Some(acc.unwrap() + local_bytes)
-                    },
-                )
-                .while_some() // allow short-circuiting if size limit is reached
-                .sum::<usize>();
-            repo_bytes = atomic_bytes.load(Ordering::Relaxed);
-
-            for dir in parsed_repo.all_files.iter().filter(|x| x.kind == "dir") {
-                size_check!(repo_desc, repo_bytes, total_bytes, break);
-                let listing = dir_listing(&repo, &dir).expect("Failed to parse file.");
-                local_ctx.insert("dir", dir);
-                local_ctx
-                    .try_insert("files", &listing)
-                    .expect("Failed to add dir to template engine.");
-                if let Some(templ_file) = self.settings.templates.dir.as_deref() {
-                    match tera.render(templ_file, &local_ctx) {
-                        Ok(rendered) => {
-                            repo_bytes +=
-                                self.write_rendered(&self.settings.outputs.dir(Some(&parsed_repo), Some(dir)), &rendered);
-                        }
-                        Err(x) => match x.kind {
-                            _ => error!("ERROR: {:?}", x),
-                        },
-                    }
-                }
-                local_ctx.remove("files");
-                local_ctx.remove("dir");
-            }
-
-            if let Some(templ_file) = self.settings.templates.files.as_deref() {
-                let mut local_ctx = local_ctx.clone();
-                local_ctx.insert("root_files", &parsed_repo.root_files);
-                local_ctx.insert("all_files", &parsed_repo.all_files);
-                match tera.render(templ_file, &local_ctx) {
-                    Ok(rendered) => {
-                        repo_bytes +=
-                            self.write_rendered(&self.settings.outputs.files::<GitFile>(Some(&parsed_repo), None), &rendered);
-                    }
-                    Err(x) => match x.kind {
-                        _ => error!("ERROR: {:?}", x),
-                    },
-                }
-            }
+            repo_bytes += self.gen_summary( &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_branches(&local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_branch(  &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_tags(    &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_tag(     &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_history( &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_commit(  &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_file(    &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_dir(     &local_ctx, &parsed_repo, repo_desc, &repo)?;
+            repo_bytes += self.gen_files(   &local_ctx, &parsed_repo, repo_desc, &repo)?;
 
-            if repo_desc.asset_files.is_some() {
-                let target_dir = self.settings.outputs.repo_assets::<GitFile>(Some(&parsed_repo), None);
-                for src_file in repo_desc.asset_files.as_ref().unwrap() {
-                    let src_file = src_file.replace("%TEMPLATE%", &self.settings.templates.template_dir());
-                    let src_file = src_file.replace("%REPO%", &repo_path);
-                    let src_file = PathBuf::from(repo_path.to_owned() + "/" + &src_file);
-                    let mut dst_file = PathBuf::from(&target_dir);
-                    dst_file.push(src_file.file_name().expect(&format!(
-                        "Failed to copy repo asset file: {} ({})",
-                        src_file.display(),
-                        repo_desc.name.as_deref().unwrap_or_default()
-                    )));
-                    std::fs::copy(&src_file, &dst_file).expect(&format!(
-                        "Failed to copy repo asset file: {} ({})",
-                        src_file.display(),
-                        repo_desc.name.as_deref().unwrap_or_default()
-                    ));
-                    if let Ok(meta) = std::fs::metadata(dst_file) {
-                        repo_bytes += meta.len() as usize;
-                    }
-                    loud!(" - copied asset: {}", src_file.display());
-                }
-            }
+            self.copy_assets(Some(&repo_desc), Some(&parsed_repo), Some(&repo))?;
 
             repos.push(minimized_repo);
             normal!(
                 "{}done in {:.2}s ({} bytes)",
-                match crate::util::VERBOSITY.load(Ordering::Relaxed) > 1 {
+                match VERBOSITY.load(Ordering::Relaxed) > 1 {
                     true => " - ",
                     _ => "",
                 },
@@ -674,72 +812,14 @@ impl GitsyGenerator {
             global_name,
             " ".repeat(longest_repo_name - global_name.len())
         );
-        let mut global_ctx = Context::new();
-        global_ctx
-            .try_insert("repos", &repos)
-            .expect("Failed to add repo to template engine.");
-        if let Some(extra) = &self.settings.extra {
-            global_ctx
-                .try_insert("extra", extra)
-                .expect("Failed to add extra settings to template engine.");
-        }
-        if let Some(site_name) = &self.settings.site_name {
-            global_ctx.insert("site_name", site_name);
-        }
-        if let Some(site_url) = &self.settings.site_url {
-            global_ctx.insert("site_url", site_url);
-        }
-        if let Some(site_description) = &self.settings.site_description {
-            global_ctx.insert("site_description", site_description);
-        }
-        global_ctx.insert("site_dir", &self.settings.outputs.output_dir());
-        if self.settings.outputs.global_assets.is_some() {
-            global_ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets::<GitFile>(None, None)));
-        }
-        global_ctx.insert("site_generated_ts", &generated_dt.timestamp());
-        global_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc());
+        let mut global_ctx = self.new_context(None)?;
+        global_ctx.try_insert("repos", &repos)?;
 
-        if let Some(templ_file) = self.settings.templates.repo_list.as_deref() {
-            match tera.render(templ_file, &global_ctx) {
-                Ok(rendered) => {
-                    global_bytes += self.write_rendered(&self.settings.outputs.repo_list::<GitFile>(None, None), &rendered);
-                }
-                Err(x) => match x.kind {
-                    _ => error!("ERROR: {:?}", x),
-                },
-            }
-        }
-
-        if let Some(templ_file) = self.settings.templates.error.as_deref() {
-            match tera.render(templ_file, &global_ctx) {
-                Ok(rendered) => {
-                    global_bytes += self.write_rendered(&self.settings.outputs.error::<GitFile>(None, None), &rendered);
-                }
-                Err(x) => match x.kind {
-                    _ => error!("ERROR: {:?}", x),
-                },
-            }
-        }
+        let mut global_bytes = 0;
+        global_bytes += self.gen_repo_list(&global_ctx)?;
+        global_bytes += self.gen_error(&global_ctx)?;
 
-        if self.settings.asset_files.is_some() {
-            let target_dir = self.settings.outputs.global_assets::<GitFile>(None, None);
-            for src_file in self.settings.asset_files.as_ref().unwrap() {
-                let src_file = src_file.replace("%TEMPLATE%", &self.settings.templates.template_dir());
-                let src_file = PathBuf::from(src_file);
-                let mut dst_file = PathBuf::from(&target_dir);
-                dst_file.push(
-                    src_file
-                        .file_name()
-                        .expect(&format!("Failed to copy asset file: {}", src_file.display())),
-                );
-                std::fs::copy(&src_file, &dst_file)
-                    .expect(&format!("Failed to copy asset file: {}", src_file.display()));
-                if let Ok(meta) = std::fs::metadata(dst_file) {
-                    global_bytes += meta.len() as usize;
-                }
-                loud!(" - copied asset: {}", src_file.display());
-            }
-        }
+        self.copy_assets(None, None, None)?;
 
         total_bytes += global_bytes;
         normal!(
@@ -754,7 +834,9 @@ impl GitsyGenerator {
         );
 
         if self.cli.should_open {
-            let _ = open::that(&format!("file://{}", self.settings.outputs.repo_list::<GitFile>(None, None).display()));
+            if let Some((_templ, out)) = self.settings.outputs.repo_list::<GitFile>(None, None).first() {
+                let _ = open::that(&format!("file://{}", out.display()));
+            }
         }
 
         Ok(())

diff --git a/src/main.rs b/src/main.rs
line changes: +1/-1
index 9bfd0ce..e12f48d
--- a/src/main.rs
+++ b/src/main.rs
@@ -41,6 +41,6 @@ use settings::{GitsyCli, GitsySettings};
 fn main() {
     let cli = GitsyCli::new();
     let (settings, repo_descriptions) = GitsySettings::new(&cli);
-    let generator = GitsyGenerator::new(cli, settings, repo_descriptions);
+    let mut generator = GitsyGenerator::new(cli, settings, repo_descriptions);
     generator.generate().expect("Itsy-Gitsy generation failed!");
 }

diff --git a/src/settings.rs b/src/settings.rs
line changes: +142/-74
index 8aa01ab..e1b9a06
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -20,10 +20,11 @@
  * You should have received a copy of the GNU General Public License
  * along with Itsy-Gitsy.  If not, see <http://www.gnu.org/licenses/>.
  */
-use crate::error;
+use crate::{louder, error};
 use crate::git::GitRepo;
 use crate::util::SafePathVar;
 use clap::Parser;
+use git2::Repository;
 use serde::Deserialize;
 use std::collections::{BTreeMap, HashMap, HashSet};
 use std::fs::{create_dir, create_dir_all, read_dir, remove_dir_all, read_to_string};
@@ -112,55 +113,19 @@ impl GitsyCli {
 }
 
 #[derive(Deserialize, Debug)]
-pub struct GitsySettingsTemplates {
-    pub path: PathBuf,
-    pub repo_list: Option<String>,
-    pub summary: Option<String>,
-    pub history: Option<String>,
-    pub commit: Option<String>,
-    pub branches: Option<String>,
-    pub branch: Option<String>,
-    pub tags: Option<String>,
-    pub tag: Option<String>,
-    pub files: Option<String>,
-    pub file: Option<String>,
-    pub dir: Option<String>,
-    pub error: Option<String>,
-}
-
-impl GitsySettingsTemplates {
-    pub fn template_dir(&self) -> String {
-        self.path.clone().canonicalize()
-            .expect(&format!("ERROR: unable to canonicalize template path: {}", self.path.display()))
-            .to_str().expect(&format!("ERROR: unable to parse template path: {}", self.path.display()))
-            .to_string()
-    }
-}
-
-#[derive(Deserialize, Debug)]
 pub struct GitsySettingsOutputs {
-    pub path: PathBuf,
+    pub output_root: PathBuf,
+    pub template_root: PathBuf,
+    pub templates: Option<Vec<GitsySettingsTemplate>>,
     pub cloned_repos: Option<String>,
-    pub repo_list: Option<String>,
-    pub summary: Option<String>,
-    pub history: Option<String>,
-    pub commit: Option<String>,
-    pub branches: Option<String>,
-    pub branch: Option<String>,
-    pub tags: Option<String>,
-    pub tag: Option<String>,
-    pub files: Option<String>,
-    pub file: Option<String>,
-    pub dir: Option<String>,
-    pub error: Option<String>,
     pub syntax_css: Option<String>,
     pub global_assets: Option<String>,
     pub repo_assets: Option<String>,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, PartialEq)]
 #[allow(non_camel_case_types)]
-pub enum GitsySettingsExtraType {
+pub enum GitsyTemplateType {
     repo_list,
     summary,
     history,
@@ -171,7 +136,8 @@ pub enum GitsySettingsExtraType {
     tag,
     files,
     file,
-    dir
+    dir,
+    error,
 }
 
 pub fn substitute_path_vars<P,S>(path: &P, repo: Option<&GitRepo>, obj: Option<&S>) -> PathBuf
@@ -185,13 +151,13 @@ where P: AsRef<Path>,
 }
 
 #[derive(Deserialize, Debug)]
-pub struct GitsySettingsExtraOutput {
+pub struct GitsySettingsTemplate {
     pub template: String,
     pub output: String,
-    pub kind: GitsySettingsExtraType,
+    pub kind: GitsyTemplateType,
 }
 
-macro_rules! output_path_fn {
+macro_rules! template_fn {
     ($var:ident, $is_dir:expr, $default:expr) => {
         pub fn $var<S: SafePathVar>(&self, repo: Option<&GitRepo>, obj: Option<&S>) -> PathBuf {
             let tmpl_path = PathBuf::from(self.$var.as_deref().unwrap_or($default));
@@ -201,29 +167,70 @@ macro_rules! output_path_fn {
     }
 }
 
+macro_rules! templates_fn {
+    ($var:ident, $is_dir:expr) => {
+        pub fn $var<S: SafePathVar>(&self, repo: Option<&GitRepo>, obj: Option<&S>) -> Vec<(PathBuf, PathBuf)> {
+            match &self.templates {
+                Some(template) => {
+                    template.iter()
+                        .filter(|x| x.kind == GitsyTemplateType::$var)
+                        .map(|x| {
+                            let tmpl_path = PathBuf::from(&x.output);
+                            let new_path = substitute_path_vars(&tmpl_path, repo, obj);
+                            (PathBuf::from(&x.template),
+                             self.canonicalize_and_create(&new_path, $is_dir))
+                        }).collect()
+                },
+                None => {
+                    vec!()
+                },
+            }
+        }
+    }
+}
+
+impl SafePathVar for GitsySettingsOutputs {
+    fn safe_substitute(&self, path: &impl AsRef<Path>) -> PathBuf {
+        let src: &Path = path.as_ref();
+        let mut dst = PathBuf::new();
+        let root = self.template_root.to_str()
+            .expect(&format!("ERROR: couldn't parse template root: {}", self.template_root.display()));
+        for cmp in src.components() {
+            // NOTE: this variable is not sanitized, since it's
+            // allowed to create new directory structure.
+            let cmp = cmp.as_os_str().to_string_lossy()
+                .replace("%TEMPLATE%", &root);
+            dst.push(cmp);
+        }
+        dst
+    }
+}
+
 #[rustfmt::skip]
 impl GitsySettingsOutputs {
-    output_path_fn!(repo_list,     false, "index.html");
-    output_path_fn!(summary,       false, "%REPO%/index.html");
-    output_path_fn!(history,       false, "%REPO%/history%PAGE%.html");
-    output_path_fn!(commit,        false, "%REPO%/commit/%ID%.html");
-    output_path_fn!(branches,      false, "%REPO%/branches%PAGE%.html");
-    output_path_fn!(branch,        false, "%REPO%/branch/%ID%.html");
-    output_path_fn!(tags,          false, "%REPO%/tags%PAGE%.html");
-    output_path_fn!(tag,           false, "%REPO%/tag/%ID%.html");
-    output_path_fn!(files,         false, "%REPO%/files.html");
-    output_path_fn!(file,          false, "%REPO%/file/%ID%.html");
-    output_path_fn!(syntax_css,    false, "%REPO%/file/syntax.css");
-    output_path_fn!(dir,           false, "%REPO%/dir/%ID%.html");
-    output_path_fn!(error,         false, "404.html");
-    output_path_fn!(global_assets, true,  "assets/");
-    output_path_fn!(repo_assets,   true,  "%REPO%/assets/");
+    // Single entries:
+    template_fn!(syntax_css,    false, "%REPO%/file/syntax.css");
+    template_fn!(global_assets, true,  "assets/");
+    template_fn!(repo_assets,   true,  "%REPO%/assets/");
+    // Zero or more entries (Vec):
+    templates_fn!(repo_list, false);
+    templates_fn!(summary, false);
+    templates_fn!(history, false);
+    templates_fn!(commit, false);
+    templates_fn!(branches, false);
+    templates_fn!(branch, false);
+    templates_fn!(tags, false);
+    templates_fn!(tag, false);
+    templates_fn!(files, false);
+    templates_fn!(file, false);
+    templates_fn!(dir, false);
+    templates_fn!(error, false);
 
     fn canonicalize_and_create(&self, path: &Path, is_dir: bool) -> PathBuf {
-        let mut canonical_path = self.path.clone()
+        let mut canonical_path = self.output_root.clone()
             .canonicalize().expect(&format!(
                 "ERROR: unable to canonicalize output path: {}",
-                self.path.display()));
+                self.output_root.display()));
         canonical_path.push(path);
         match is_dir {
             true => {
@@ -238,21 +245,49 @@ impl GitsySettingsOutputs {
         canonical_path
     }
 
-    pub fn output_dir(&self) -> String {
-        self.path.clone().canonicalize()
-            .expect(&format!("ERROR: unable to canonicalize output path: {}", self.path.display()))
-            .to_str().expect(&format!("ERROR: unable to parse output path: {}", self.path.display()))
-            .to_string()
+    pub fn output_dir(&self) -> PathBuf {
+        self.output_root.clone().canonicalize()
+            .expect(&format!("ERROR: unable to canonicalize output path: {}", self.output_root.display()))
+    }
+
+    pub fn template_dir(&self) -> PathBuf {
+        self.template_root.clone().canonicalize()
+            .expect(&format!("ERROR: unable to canonicalize template path: {}", self.template_root.display()))
+    }
+
+    pub fn has_files(&self) -> bool {
+        match &self.templates {
+            Some(template) => template.iter().filter(|x| x.kind == GitsyTemplateType::file).count() > 0,
+            _ => false,
+        }
+    }
+
+    pub fn asset<P: AsRef<Path>>(&self, asset: &P, parsed_repo: Option<&GitRepo>, repo: Option<&Repository>) -> PathBuf {
+        let tmpl_path = asset.as_ref().to_path_buf();
+        let asset_path = substitute_path_vars(&tmpl_path, parsed_repo, Some(self));
+        let full_path = match repo {
+            Some(repo) => {
+                let mut full_path = repo.path().to_owned();
+                full_path.push(asset_path);
+                full_path
+            },
+            _ => {
+                asset_path
+            }
+        };
+        full_path
     }
 
     pub fn create(&self) {
-        let _ = create_dir(self.path.to_str().expect(&format!("ERROR: output path invalid: {}", self.path.display())));
+        louder!("Creating output directory: {}", self.output_root.display());
+        let _ = create_dir(self.output_root.to_str().expect(&format!("ERROR: output path invalid: {}", self.output_root.display())));
     }
 
     pub fn clean(&self) {
-        if !self.path.exists() {
+        if !self.output_root.exists() {
             return;
         }
+        louder!("Cleaning output directory: {}", self.output_root.display());
         let dir: PathBuf = PathBuf::from(&self.output_dir());
         assert!(dir.is_dir(), "ERROR: Output directory is... not a directory? {}", dir.display());
         remove_dir_all(&dir)
@@ -270,6 +305,41 @@ impl GitsySettingsOutputs {
             .expect(&format!("ERROR: Unable to make path relative: {}", path))
             .to_string()
     }
+
+    pub fn assert_valid<P: AsRef<Path>>(&self, path: &P) -> bool {
+        let path = path.as_ref().to_str()
+            .expect(&format!("ERROR: attempted to write unrecognizeable path: {}", path.as_ref().display()));
+        // Ensure that the requested output path is actually a child
+        // of the output directory, as a sanity check to ensure we
+        // aren't writing out of bounds.
+        let canonical_root = self.output_root.canonicalize().expect(&format!(
+            "Cannot find canonical version of output path: {}",
+            self.output_root.display()
+        ));
+        let canonical_path = PathBuf::from(path);
+        let has_relative_dirs = canonical_path
+            .ancestors()
+            .any(|x| x.file_name().is_none() && x != Path::new("/"));
+        assert!(
+            canonical_path.is_absolute(),
+            "ERROR: write_rendered called with a relative path: {}",
+            path
+        );
+        assert!(
+            !has_relative_dirs,
+            "ERROR: write_rendered called with a relative path: {}",
+            path
+        );
+        let _ = canonical_path
+            .ancestors()
+            .find(|x| x == &canonical_root)
+            .expect(&format!(
+                "Output file {} not contained in output path: {}",
+                canonical_path.display(),
+                canonical_root.display()
+            ));
+        true
+    }
 }
 
 #[derive(Clone, Deserialize, Default, Debug)]
@@ -326,10 +396,6 @@ pub struct GitsySettings {
     pub readme_files: Option<Vec<String>>,
     pub asset_files: Option<Vec<String>>,
     pub branch: Option<String>,
-    #[serde(rename(deserialize = "gitsy_templates"))]
-    pub templates: GitsySettingsTemplates,
-    #[serde(rename(deserialize = "gitsy_outputs"))]
-    pub outputs: GitsySettingsOutputs,
     pub paginate_history: Option<usize>,
     pub paginate_branches: Option<usize>,
     pub paginate_tags: Option<usize>,
@@ -346,6 +412,8 @@ pub struct GitsySettings {
     pub render_markdown: Option<bool>,
     pub syntax_highlight: Option<bool>,
     pub syntax_highlight_theme: Option<String>,
+    #[serde(rename(deserialize = "gitsy_outputs"))]
+    pub outputs: GitsySettingsOutputs,
     #[serde(rename(deserialize = "gitsy_extra"))]
     pub extra: Option<BTreeMap<String, toml::Value>>,
 }