summary history branches tags files
commit:d5428f97b79c060b6fda10ec983de8216786e4d3
author:Trevor Bentley
committer:Trevor Bentley
date:Wed Jan 11 00:39:38 2023 +0100
parents:4e436a268d2cb4227841e4f586480df0f9e5162a
better header/footer template, add syntax highlighting
diff --git a/Cargo.lock b/Cargo.lock
line changes: +246/-1
index e30c466..77326d1
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,6 +3,12 @@
 version = 3
 
 [[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
 name = "aho-corasick"
 version = "0.7.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -27,6 +33,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
 [[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
 name = "bitflags"
 version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -82,7 +118,7 @@ dependencies = [
  "js-sys",
  "num-integer",
  "num-traits",
- "time",
+ "time 0.1.45",
  "wasm-bindgen",
  "winapi",
 ]
@@ -172,6 +208,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "crc32fast"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
 name = "crypto-common"
 version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -263,6 +308,26 @@ dependencies = [
 ]
 
 [[package]]
+name = "fancy-regex"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6b8560a05112eb52f04b00e5d3790c0dd75d9d980eb8a122fb23b92a623ccf"
+dependencies = [
+ "bit-set",
+ "regex",
+]
+
+[[package]]
+name = "flate2"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
 name = "fnv"
 version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -288,6 +353,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "getopts"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
 name = "getrandom"
 version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -338,6 +412,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
 name = "heck"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -410,6 +490,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "indexmap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+]
+
+[[package]]
 name = "io-lifetimes"
 version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -444,7 +534,9 @@ dependencies = [
  "chrono",
  "clap",
  "git2",
+ "pulldown-cmark",
  "serde",
+ "syntect",
  "tera",
  "toml",
 ]
@@ -520,6 +612,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "line-wrap"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9"
+dependencies = [
+ "safemem",
+]
+
+[[package]]
 name = "link-cplusplus"
 version = "1.0.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -529,6 +630,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
 name = "linux-raw-sys"
 version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -550,6 +657,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
 
 [[package]]
+name = "miniz_oxide"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa"
+dependencies = [
+ "adler",
+]
+
+[[package]]
 name = "num-integer"
 version = "0.1.45"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -575,6 +691,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66"
 
 [[package]]
+name = "onig"
+version = "6.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
 name = "openssl-probe"
 version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -704,6 +842,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160"
 
 [[package]]
+name = "plist"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225"
+dependencies = [
+ "base64",
+ "indexmap",
+ "line-wrap",
+ "serde",
+ "time 0.3.17",
+ "xml-rs",
+]
+
+[[package]]
 name = "ppv-lite86"
 version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -743,6 +895,18 @@ dependencies = [
 ]
 
 [[package]]
+name = "pulldown-cmark"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63"
+dependencies = [
+ "bitflags",
+ "getopts",
+ "memchr",
+ "unicase",
+]
+
+[[package]]
 name = "quote"
 version = "1.0.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -819,6 +983,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde"
 
 [[package]]
+name = "safemem"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
+
+[[package]]
 name = "same-file"
 version = "1.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -908,6 +1078,30 @@ dependencies = [
 ]
 
 [[package]]
+name = "syntect"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6c454c27d9d7d9a84c7803aaa3c50cd088d2906fe3c6e42da3209aa623576a8"
+dependencies = [
+ "bincode",
+ "bitflags",
+ "fancy-regex",
+ "flate2",
+ "fnv",
+ "lazy_static",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
 name = "tera"
 version = "1.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -979,6 +1173,33 @@ dependencies = [
 ]
 
 [[package]]
+name = "time"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376"
+dependencies = [
+ "itoa",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd"
+
+[[package]]
+name = "time-macros"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2"
+dependencies = [
+ "time-core",
+]
+
+[[package]]
 name = "tinyvec"
 version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1074,6 +1295,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "unicase"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
 name = "unicode-bidi"
 version = "0.3.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1287,3 +1517,18 @@ name = "windows_x86_64_msvc"
 version = "0.42.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]

diff --git a/Cargo.toml b/Cargo.toml
line changes: +7/-0
index 24cddf9..d0a0c4a
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,11 +11,18 @@ repository = "https://github.com/mrmekon/itsy-gitsy"
 readme = "README.md"
 license = "GPL-3.0-or-later"
 
+[features]
+default = ["markdown", "highlight"]
+markdown = ["dep:pulldown-cmark"]
+highlight_fast = ["syntect/default-onig"]
+highlight = ["syntect/default-fancy"]
 
 [dependencies]
 chrono = { version = "0.4.23", features=["clock"] }
 clap = { version="4.0.32", features=["derive"] }
 git2 = "0.15.0"
+pulldown-cmark = { version = "0.9.2", optional = true }
 serde = { version = "1.0.152", features = ["derive"] }
+syntect = { version = "5.0.0", default-features = false, option = true }
 tera = "1.17.1"
 toml = "0.5.10"

diff --git a/settings.toml b/settings.toml
line changes: +12/-2
index 64864e0..64d51ff
--- a/settings.toml
+++ b/settings.toml
@@ -2,10 +2,20 @@ site_name = "Trevor's Repos"
 site_url = "https://git.trevorbentley.com"
 site_description = "A bunch of git repos in a stupid format."
 
+render_markdown = true
+syntax_highlight = true
+syntax_highlight_theme = "base16-ocean.light"
+
 output_dir = "gen/"
 
 recursive_repo_dirs = ["repos/"]
 
+# TODO: specify limits
+# TODO: specify output directories/filenames
+# TODO: extra metadata for recursive repo listings
+# TODO: per-site and per-repo resources (just copied to output dir)
+# TODO: enable/disable markdown/highlighting
+# TODO: highlighting theme, extensions
 [gitsy_templates]
 path = "templates/"
 repo_list = "repos.html"
@@ -15,8 +25,6 @@ branch = "branch.html"
 tag = "tag.html"
 file = "file.html"
 dir = "dir.html"
-header = "header.html"
-footer = "footer.html"
 error = "404.html"
 
 [gitsy_extra]
@@ -28,3 +36,5 @@ or_bools = true
 path = "more_repos/circadian"
 website = "https://circadian.trevorbentley.com"
 attributes = {some_extra_thing = "user defined", visible = false, number_of_bananas = 3}
+render_markdown = false
+syntax_highlighting = false

diff --git a/src/main.rs b/src/main.rs
line changes: +105/-36
index 7685d17..c9a234b
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,6 +12,17 @@ use std::io::Write;
 use std::path::{Path, PathBuf};
 use tera::{Context, Filter, Function, Tera, Value, to_value, try_get_value};
 
+#[cfg(feature = "markdown")]
+use pulldown_cmark::{html, Options, Parser as MdParser};
+
+#[cfg(any(feature = "highlight", feature = "highlight_fast"))]
+use syntect::{
+    html::{ClassedHTMLGenerator, ClassStyle, css_for_theme_with_class_style},
+    parsing::SyntaxSet,
+    highlighting::ThemeSet,
+    util::LinesWithEndings,
+};
+
 fn ts_to_date(ts: i64, offset: Option<i64>, format: Option<String>) -> String {
     let offset = offset.unwrap_or(0);
     let dt = NaiveDateTime::from_timestamp_opt(ts + offset, 0).expect("Invalid timestamp");
@@ -97,6 +108,8 @@ struct GitFile {
     is_binary: bool,
     size: usize,
     contents: Option<String>,
+    contents_safe: bool,
+    contents_preformatted: bool,
 }
 
 #[derive(Serialize, Default)]
@@ -164,6 +177,8 @@ fn walk_file_tree(repo: &git2::Repository, rev: &str, files: &mut Vec<GitFile>,
             is_binary,
             size,
             contents: None,
+            contents_safe: false,
+            contents_preformatted: true,
         });
         if recurse && entry.kind() == Some(git2::ObjectType::Tree) {
             let prefix = name + "/";
@@ -455,12 +470,61 @@ fn parse_commit(repo: &Repository, refr: &str) -> Result<GitObject, Error> {
     Ok(summary)
 }
 
-fn fill_file_contents(repo: &Repository, file: &GitFile) -> Result<GitFile, Error> {
+#[cfg(feature = "markdown")]
+fn parse_markdown(contents: &str) -> String {
+    let mut options = Options::empty();
+    options.insert(Options::ENABLE_STRIKETHROUGH);
+    options.insert(Options::ENABLE_TABLES);
+    let parser = MdParser::new_ext(contents, options);
+    let mut html_output: String = String::with_capacity(contents.len() * 3 / 2);
+    html::push_html(&mut html_output, parser);
+    html_output
+}
+
+#[cfg(any(feature = "highlight", feature = "highlight_fast"))]
+fn syntax_highlight(contents: &str, extension: &str) -> String {
+    let syntax_set = SyntaxSet::load_defaults_newlines();
+    let syntax = match syntax_set.find_syntax_by_extension(extension) {
+        Some(s) => s,
+        _ => { return contents.to_string(); },
+    };
+    let mut html_generator = ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced);
+    for line in LinesWithEndings::from(contents) {
+        match html_generator.parse_html_for_line_which_includes_newline(line) {
+            Ok(_) => {},
+            Err(_) => {
+                println!("Warning: failed to apply syntax highlighting.");
+                return contents.to_string();
+            },
+        }
+    }
+    html_generator.finalize()
+}
+
+fn fill_file_contents(repo: &Repository, file: &GitFile, settings: &GitsySettingsRepo) -> Result<GitFile, Error> {
     let mut file = file.clone();
     if file.kind == "file" {
         let blob = repo.find_blob(git2::Oid::from_str(&file.id)?)?;
         file.contents = match blob.is_binary() {
-            false => Some(String::from_utf8_lossy(blob.content()).to_string()),
+            false => {
+                let path = Path::new(&file.path);
+                let cstr = String::from_utf8_lossy(blob.content()).to_string();
+                let (content, rendered, pre) = match path.extension() {
+                    #[cfg(feature = "markdown")]
+                    Some(x) if settings.render_markdown.unwrap_or(false) && x == "md" => {
+                        let (cstr, rendered, pre) = (parse_markdown(&cstr), true, false);
+                        (cstr, rendered, pre)
+                    },
+                    #[cfg(any(feature = "highlight", feature = "highlight_fast"))]
+                    Some(x) if settings.syntax_highlight.unwrap_or(false) => {
+                        (syntax_highlight(&cstr, x.to_string_lossy().to_string().as_str()), true, true)
+                    },
+                    _ => (cstr, false, true),
+                };
+                file.contents_safe = rendered;
+                file.contents_preformatted = pre;
+                Some(content)
+            },
             true => Some(format!("[Binary data ({} bytes)]", blob.content().len())),
         };
     }
@@ -570,6 +634,9 @@ struct GitsySettings {
     site_description: Option<String>,
     #[serde(rename(deserialize = "gitsy_templates"))]
     templates: GitsySettingsTemplates,
+    render_markdown: Option<bool>,
+    syntax_highlight: Option<bool>,
+    syntax_highlight_theme: Option<String>,
     #[serde(rename(deserialize = "gitsy_extra"))]
     extra: Option<BTreeMap<String, toml::Value>>,
 }
@@ -577,8 +644,6 @@ struct GitsySettings {
 #[derive(Deserialize)]
 struct GitsySettingsTemplates {
     path: PathBuf,
-    header: Option<String>,
-    footer: Option<String>,
     repo_list: Option<String>,
     repo_summary: Option<String>,
     commit: Option<String>,
@@ -595,6 +660,9 @@ struct GitsySettingsRepo {
     name: Option<String>,
     description: Option<String>,
     website: Option<String>,
+    render_markdown: Option<bool>,
+    syntax_highlight: Option<bool>,
+    syntax_highlight_theme: Option<String>,
     attributes: BTreeMap<String, toml::Value>,
 }
 
@@ -611,14 +679,8 @@ impl PartialEq for GitsySettingsRepo {
 }
 impl Eq for GitsySettingsRepo {}
 
-fn write_rendered(file: &mut File, rendered: &str, header: Option<&str>, footer: Option<&str>) {
-    if let Some(header) = header {
-        file.write(header.as_bytes()).expect("failed to save rendered html");
-    }
+fn write_rendered(file: &mut File, rendered: &str) {
     file.write(rendered.as_bytes()).expect("failed to save rendered html");
-    if let Some(footer) = footer {
-        file.write(footer.as_bytes()).expect("failed to save rendered html");
-    }
 }
 
 fn main() {
@@ -667,6 +729,9 @@ fn main() {
                     let dir = dir.expect("Repo contains invalid entries");
                     repo_descriptions.insert(GitsySettingsRepo {
                         path: dir.path().clone(),
+                        render_markdown: settings.render_markdown.clone(),
+                        syntax_highlight: settings.syntax_highlight.clone(),
+                        syntax_highlight_theme: settings.syntax_highlight_theme.clone(),
                         ..Default::default()
                     });
                 }
@@ -729,14 +794,6 @@ fn main() {
         }
         local_ctx.insert("site_generated_ts", &generated_dt.timestamp());
         local_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc());
-        let header: Option<String> = match &settings.templates.header {
-            Some(header) => Some(tera.render(header, &local_ctx).expect("Unable to templatize header file")),
-            _ => None,
-        };
-        let footer: Option<String> = match &settings.templates.footer {
-            Some(footer) => Some(tera.render(footer, &local_ctx).expect("Unable to templatize footer file")),
-            _ => None,
-        };
 
         match tera.render(&settings.templates.repo_summary.as_deref().unwrap_or("summary.html"), &local_ctx) {
             Ok(rendered) => {
@@ -745,7 +802,7 @@ fn main() {
                 let _ = std::fs::create_dir(output_path.to_str().expect("Output path not set!"));
                 output_path.push("summary.html");
                 let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-                write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                write_rendered(&mut file, &rendered);
             },
             Err(x) => match x.kind {
                 tera::ErrorKind::TemplateNotFound(_) if settings.templates.repo_summary.is_none() => {},
@@ -763,7 +820,7 @@ fn main() {
                     let _ = std::fs::create_dir(output_path.to_str().expect("Output path not set!"));
                     output_path.push(format!("{}.html", branch.full_hash));
                     let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                    write_rendered(&mut file, &rendered);
                 },
                 Err(x) => match x.kind {
                     tera::ErrorKind::TemplateNotFound(_) if settings.templates.branch.is_none() => {},
@@ -786,7 +843,7 @@ fn main() {
                     let _ = std::fs::create_dir(output_path.to_str().expect("Output path not set!"));
                     output_path.push(format!("{}.html", tag.full_hash));
                     let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                    write_rendered(&mut file, &rendered);
                 },
                 Err(x) => match x.kind {
                     tera::ErrorKind::TemplateNotFound(_) if settings.templates.tag.is_none() => {},
@@ -807,7 +864,7 @@ fn main() {
                     let _ = std::fs::create_dir(output_path.to_str().expect("Output path not set!"));
                     output_path.push(format!("{}.html", commit.full_hash));
                     let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                    write_rendered(&mut file, &rendered);
                 },
                 Err(x) => match x.kind {
                     tera::ErrorKind::TemplateNotFound(_) if settings.templates.commit.is_none() => {},
@@ -817,8 +874,28 @@ fn main() {
             local_ctx.remove("commit");
         }
 
+        // TODO: most of these generation blocks can be done in
+        // parallel.  This one is particularly costly, especially with
+        // markdown+highlighting, and would probably benefit from it.
+        // A potential drawback is that each parallel run needs a
+        // clone of the Tera context.
+        #[cfg(any(feature = "highlight", feature = "highlight_fast"))]
+        {
+            let ts = ThemeSet::load_defaults();
+            let theme = ts.themes.get(repo_desc.syntax_highlight_theme.as_deref()
+                                      .unwrap_or("base16-ocean.light")).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.");
+            let mut output_path = settings.output_dir.clone();
+            output_path.push(&summary.name);
+            output_path.push("file");
+            output_path.push("syntax.css");
+            let mut file = std::fs::File::create(output_path.to_str().expect("CSS filename invalid!")).expect("CSS path not writeable!");
+            file.write(css.as_bytes()).expect("Failed to write CSS file!");
+        }
+
         for file in summary.all_files.iter().filter(|x| x.kind == "file") {
-            let file = fill_file_contents(&repo, &file).expect("Failed to parse file.");
+            let file = fill_file_contents(&repo, &file, &repo_desc).expect("Failed to parse file.");
             local_ctx.try_insert("file", &file).expect("Failed to add file to template engine.");
             match tera.render(&settings.templates.file.as_deref().unwrap_or("file.html"), &local_ctx) {
                 Ok(rendered) => {
@@ -828,7 +905,7 @@ fn main() {
                     let _ = std::fs::create_dir(output_path.to_str().expect("Output path not set!"));
                     output_path.push(format!("{}.html", file.id));
                     let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                    write_rendered(&mut file, &rendered);
                 },
                 Err(x) => match x.kind {
                     tera::ErrorKind::TemplateNotFound(_) if settings.templates.file.is_none() => {},
@@ -849,7 +926,7 @@ fn main() {
                     let _ = std::fs::create_dir(output_path.to_str().expect("Output path not set!"));
                     output_path.push(format!("{}.html", dir.id));
                     let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                    write_rendered(&mut file, &rendered);
                 },
                 Err(x) => match x.kind {
                     tera::ErrorKind::TemplateNotFound(_) if settings.templates.dir.is_none() => {},
@@ -878,21 +955,13 @@ fn main() {
     }
     global_ctx.insert("site_generated_ts", &generated_dt.timestamp());
     global_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc());
-    let header: Option<String> = match &settings.templates.header {
-        Some(header) => Some(tera.render(header, &global_ctx).expect("Unable to templatize header file")),
-        _ => None,
-    };
-    let footer: Option<String> = match &settings.templates.footer {
-        Some(footer) => Some(tera.render(footer, &global_ctx).expect("Unable to templatize footer file")),
-        _ => None,
-    };
 
     match tera.render(&settings.templates.repo_list.as_deref().unwrap_or("repos.html"), &global_ctx) {
         Ok(rendered) => {
             let mut output_path = settings.output_dir.clone();
             output_path.push("repos.html");
             let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-            write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+            write_rendered(&mut file, &rendered);
         },
         Err(x) => match x.kind {
             tera::ErrorKind::TemplateNotFound(_) if settings.templates.repo_list.is_none() => {},
@@ -905,7 +974,7 @@ fn main() {
             let mut output_path = settings.output_dir.clone();
             output_path.push("404.html");
             let mut file = std::fs::File::create(output_path.to_str().expect("Output path not set!")).unwrap();
-            write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+            write_rendered(&mut file, &rendered);
         },
         Err(x) => match x.kind {
             tera::ErrorKind::TemplateNotFound(_) if settings.templates.error.is_none() => {},

diff --git a/templates/404.html b/templates/404.html
line changes: +4/-0
index bb95dd8..6811ef0
--- a/templates/404.html
+++ b/templates/404.html
@@ -1 +1,5 @@
+{% extends "base.html" %}
+
+{% block content %}
 The page you are seeking does not exist.
+{% endblock content %}

diff --git a/templates/base.html b/templates/base.html
line changes: +17/-0
index 0000000..ee9c416
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,17 @@
+<html>
+<head>
+{% block html_head -%}
+<title>{{site_name}}</title>
+{% block html_head_extra -%}
+{% endblock html_head_extra -%}
+{% endblock html_head -%}
+</head>
+
+<body style="font-family: monospace;">
+{% block header -%}{% include "header.html" -%}{% endblock header -%}
+
+{% block content %}{% endblock content %}
+
+{% block footer -%}{% include "footer.html" -%}{% endblock footer -%}
+</body>
+</html>

diff --git a/templates/branch.html b/templates/branch.html
line changes: +4/-0
index d349372..0731947
--- a/templates/branch.html
+++ b/templates/branch.html
@@ -1,3 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
 branch: {{branch.ref_name}}<br/>
 hash: {{branch.full_hash}} ({{branch.short_hash}})<br/>
 author: {{branch.author.name}}<br/>
@@ -5,3 +8,4 @@ committer: {{branch.committer.name}}<br/>
 date: {{ts_to_date(ts=branch.ts_utc, tz=branch.ts_offset)}}<br/>
 summary: {{branch.summary}}<br/>
 <pre>{{branch.message}}</pre>
+{% endblock content %}

diff --git a/templates/commit.html b/templates/commit.html
line changes: +4/-0
index c399d17..9afc401
--- a/templates/commit.html
+++ b/templates/commit.html
@@ -1,3 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
 <strong>
   commit: {{commit.full_hash}}<br/>
   author: {{commit.author.name}}<br/>
@@ -25,3 +28,4 @@
 </pre>
 {% endfor -%}
 {% endfor -%}
+{% endblock content %}

diff --git a/templates/dir.html b/templates/dir.html
line changes: +4/-0
index 44afb19..a56436a
--- a/templates/dir.html
+++ b/templates/dir.html
@@ -1,3 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
 <table class="files">
   <tr>
     <th>File</th>
@@ -15,3 +18,4 @@
     <td class="file-size">{{file.size}}</td>
   </tr>
   {% endfor -%}
+{% endblock content %}

diff --git a/templates/file.html b/templates/file.html
line changes: +18/-1
index 7f00ee5..26de15b
--- a/templates/file.html
+++ b/templates/file.html
@@ -1,3 +1,20 @@
+{% extends "base.html" %}
+
+{% block html_head_extra %}
+<link rel="stylesheet" type="text/css" href="syntax.css" />
+{% endblock html_head_extra %}
+
+
+{% block content %}
 {{file.path}} ({{file.name}}) [{{file.id}}]<br/>
--------
+-------<br/>
+{% if file.contents_safe -%}
+{% if file.contents_preformatted -%}
+<pre>{{file.contents | safe }}</pre>
+{%- else -%}
+{{file.contents | safe }}
+{%- endif -%}
+{%- else -%}
 <pre style="margin: 0">{{file.contents}}</pre>
+{%- endif -%}
+{% endblock content %}

diff --git a/templates/footer.html b/templates/footer.html
line changes: +2/-2
index b605728..f29b415
--- a/templates/footer.html
+++ b/templates/footer.html
@@ -1,2 +1,2 @@
-  </body>
-</html>
+<hr/>
+Generated by Itsy-Gitsy on {{ts_to_git_timestamp(ts=site_generated_ts, tz=site_generated_offset)}}<br/>

diff --git a/templates/header.html b/templates/header.html
line changes: +3/-9
index 6beb4f4..24dc11d
--- a/templates/header.html
+++ b/templates/header.html
@@ -1,9 +1,3 @@
-<html>
-  <head>
-    <title>{{site_name}}</title>
-  </head>
-  <body style="font-family: monospace;">
-    Site: {{site_name}}<br/>
-    Generated: {{ts_to_git_timestamp(ts=site_generated_ts, tz=site_generated_offset)}}<br/>
-    Extra settings: {{extra.global_user_defined_vars}}<br/>
-    <hr/>
+Site: {{site_name}}<br/>
+Extra settings: {{extra.global_user_defined_vars}}<br/>
+<hr/>

diff --git a/templates/repos.html b/templates/repos.html
line changes: +4/-0
index 693da4f..fa0a79d
--- a/templates/repos.html
+++ b/templates/repos.html
@@ -1,3 +1,7 @@
+{% extends "base.html" %}
+
+{% block content %}
 {% for repo in repos | sort(attribute="name") -%}
 <a href="{{repo.name}}/summary.html">{{ repo.name }}</a> ({{repo.metadata.website}}) [{{repo.metadata.attributes | get(key="some_extra_thing", default="")}}] ({{ts_to_date(ts=repo.history[0].ts_utc, tz=repo.history[0].ts_offset)}})<br/>
 {% endfor -%}
+{% endblock content %}

diff --git a/templates/summary.html b/templates/summary.html
line changes: +4/-0
index dcc8b6a..4d81e08
--- a/templates/summary.html
+++ b/templates/summary.html
@@ -1,3 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
 <table class="commits">
   <tr>
     <th>Commit ID</th>
@@ -77,3 +80,4 @@
   </tr>
   {% endfor -%}
 </table>
+{% endblock content %}

diff --git a/templates/tag.html b/templates/tag.html
line changes: +4/-0
index df2ff89..c21f6b9
--- a/templates/tag.html
+++ b/templates/tag.html
@@ -1,3 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
 branch: {{tag.ref_name}}<br/>
 hash: {{tag.full_hash}} ({{tag.short_hash}})<br/>
 author: {{tag.author.name}}<br/>
@@ -9,3 +12,4 @@ summary: {{tag.summary}}<br/>
 commit: {%- if commit -%}<a href="../commit/{{commit.full_hash}}.html">{{commit.full_hash}}</a>
 <pre>{{commit.message}}</pre>
 {%-else-%}{{tag.tagged_id}}{%-endif-%}<br/>
+{% endblock content %}