summary history branches tags files
commit:4e436a268d2cb4227841e4f586480df0f9e5162a
author:Trevor Bentley
committer:Trevor Bentley
date:Tue Jan 10 03:19:31 2023 +0100
parents:1951074e8fdd70eb72b3fa3b514a74e1eef3da19
more settings, headers+footers+error pages
diff --git a/Cargo.toml b/Cargo.toml
line changes: +1/-1
index 89f9634..24cddf9
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ license = "GPL-3.0-or-later"
 
 
 [dependencies]
-chrono = "0.4.23"
+chrono = { version = "0.4.23", features=["clock"] }
 clap = { version="4.0.32", features=["derive"] }
 git2 = "0.15.0"
 serde = { version = "1.0.152", features = ["derive"] }

diff --git a/settings.toml b/settings.toml
line changes: +26/-8
index b90c15e..64864e0
--- a/settings.toml
+++ b/settings.toml
@@ -1,12 +1,30 @@
-template_dir = "templates/"
+site_name = "Trevor's Repos"
+site_url = "https://git.trevorbentley.com"
+site_description = "A bunch of git repos in a stupid format."
+
 output_dir = "gen/"
 
-[a_repo]
-path = "repos/connectr"
-name = "connectr"
+recursive_repo_dirs = ["repos/"]
+
+[gitsy_templates]
+path = "templates/"
+repo_list = "repos.html"
+repo_summary = "summary.html"
+commit = "commit.html"
+branch = "branch.html"
+tag = "tag.html"
+file = "file.html"
+dir = "dir.html"
+header = "header.html"
+footer = "footer.html"
+error = "404.html"
 
-[another_repo]
-path = "repos/fruitbasket"
+[gitsy_extra]
+global_user_defined_vars = "whatever"
+these_can_also_be_numbers = 5
+or_bools = true
 
-[extra]
-thingo = 1
+[circadian]
+path = "more_repos/circadian"
+website = "https://circadian.trevorbentley.com"
+attributes = {some_extra_thing = "user defined", visible = false, number_of_bananas = 3}

diff --git a/src/main.rs b/src/main.rs
line changes: +189/-48
index 7b8f275..7685d17
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,7 +6,8 @@ use chrono::{
 use clap::Parser;
 use git2::{DiffOptions, Repository, Error};
 use serde::{Serialize, Deserialize};
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::fs::File;
 use std::io::Write;
 use std::path::{Path, PathBuf};
 use tera::{Context, Filter, Function, Tera, Value, to_value, try_get_value};
@@ -36,7 +37,7 @@ fn first_line(msg: &[u8]) -> String {
 #[derive(Serialize)]
 struct GitRepo {
     name: String,
-    metadata: ItsyMetadata,
+    metadata: GitsyMetadata,
     history: Vec<GitObject>,
     branches: Vec<GitObject>,
     tags: Vec<GitObject>,
@@ -46,12 +47,12 @@ struct GitRepo {
 }
 
 #[derive(Serialize, Default)]
-struct ItsyMetadata {
+struct GitsyMetadata {
     full_name: Option<String>,
     description: Option<String>,
     website: Option<String>,
     clone: Option<String>,
-    attributes: BTreeMap<String, String>,
+    attributes: BTreeMap<String, toml::Value>,
 }
 
 #[derive(Serialize, Default)]
@@ -172,7 +173,7 @@ fn walk_file_tree(repo: &git2::Repository, rev: &str, files: &mut Vec<GitFile>,
     Ok(())
 }
 
-fn parse_repo(repo: &Repository, name: &str) -> Result<GitRepo, Error> {
+fn parse_repo(repo: &Repository, name: &str, metadata: GitsyMetadata) -> Result<GitRepo, Error> {
     let mut history: Vec<GitObject> = vec!();
     let mut branches: Vec<GitObject> = vec!();
     let mut tags: Vec<GitObject> = vec!();
@@ -313,14 +314,14 @@ fn parse_repo(repo: &Repository, name: &str) -> Result<GitRepo, Error> {
 
     let mut root_files: Vec<GitFile> = vec!();
     let mut all_files: Vec<GitFile> = vec!();
-    walk_file_tree(&repo, "origin/HEAD", &mut root_files, 0, false, "")?;
+    walk_file_tree(&repo, "HEAD", &mut root_files, 0, false, "")?;
     // TODO: maybe this should be optional?  Walking the whole tree
     // could be slow on huge repos.
-    walk_file_tree(&repo, "origin/HEAD", &mut all_files, 0, true, "")?;
+    walk_file_tree(&repo, "HEAD", &mut all_files, 0, true, "")?;
 
     Ok(GitRepo {
         name: name.to_string(),
-        metadata: Default::default(),
+        metadata,
         history,
         branches,
         tags,
@@ -561,19 +562,63 @@ struct CliArgs {
 
 #[derive(Deserialize)]
 #[allow(dead_code)]
-struct ItsySettings {
-    template_dir: PathBuf,
+struct GitsySettings {
     output_dir: PathBuf,
     recursive_repo_dirs: Option<Vec<PathBuf>>,
-    extra: HashMap<String, toml::Value>,
+    site_name: Option<String>,
+    site_url: Option<String>,
+    site_description: Option<String>,
+    #[serde(rename(deserialize = "gitsy_templates"))]
+    templates: GitsySettingsTemplates,
+    #[serde(rename(deserialize = "gitsy_extra"))]
+    extra: Option<BTreeMap<String, toml::Value>>,
 }
+
 #[derive(Deserialize)]
-#[allow(dead_code)]
-struct ItsySettingsRepo {
+struct GitsySettingsTemplates {
+    path: PathBuf,
+    header: Option<String>,
+    footer: Option<String>,
+    repo_list: Option<String>,
+    repo_summary: Option<String>,
+    commit: Option<String>,
+    branch: Option<String>,
+    tag: Option<String>,
+    file: Option<String>,
+    dir: Option<String>,
+    error: Option<String>,
+}
+
+#[derive(Deserialize, Default)]
+struct GitsySettingsRepo {
     path: PathBuf,
     name: Option<String>,
     description: Option<String>,
     website: Option<String>,
+    attributes: BTreeMap<String, toml::Value>,
+}
+
+use std::hash::{Hash, Hasher};
+impl Hash for GitsySettingsRepo {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.path.hash(state);
+    }
+}
+impl PartialEq for GitsySettingsRepo {
+    fn eq(&self, other: &Self) -> bool {
+        self.path == other.path
+    }
+}
+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");
+    }
+    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() {
@@ -582,11 +627,11 @@ fn main() {
 
     // Parse the known settings directly into their struct
     let toml = std::fs::read_to_string(config_path).expect(&format!("Configuration file not found: {}", config_path.display()));
-    let settings: ItsySettings = toml::from_str(&toml).expect("Configuration file is invalid.");
+    let settings: GitsySettings = toml::from_str(&toml).expect("Configuration file is invalid.");
 
     // Get a list of all remaining TOML "tables" in the file.
     // These are the user-supplied individual repositories.
-    let reserved_keys = vec!("repos","extra");
+    let reserved_keys = vec!("gitsy_templates","gitsy_extra");
     let settings_raw: HashMap<String, toml::Value> = toml::from_str(&toml).expect("blah");
     let table_keys: Vec<String> = settings_raw.iter().filter_map(|x| match x.1.is_table() {
         true => match reserved_keys.contains(&x.0.as_str()) {
@@ -599,25 +644,38 @@ fn main() {
     // Try to convert each unknown "table" into a repo struct, and
     // save the ones that are successful.  If no repo name is
     // specified, use the TOML table name.
-    let mut repos: Vec<ItsySettingsRepo> = vec!();
+    let mut repo_descriptions: std::collections::HashSet<GitsySettingsRepo> = HashSet::new();
     for k in &table_keys {
         let v = settings_raw.get(k).unwrap();
-        match toml::from_str::<ItsySettingsRepo>(&v.to_string()) {
+        match toml::from_str::<GitsySettingsRepo>(&v.to_string()) {
             Ok(mut repo) => {
                 if repo.name.is_none() {
                     repo.name = Some(k.clone());
                 }
-                repos.push(repo);
+                repo_descriptions.insert(repo);
+            },
+            Err(e) => {
+                println!("Failed to parse repo [{}]: {:?}", k, e);
             },
-            _ => {},
         }
     }
 
-    for repo in &repos {
-        println!("Parse repo: {}", repo.name.as_ref().unwrap());
+    match settings.recursive_repo_dirs {
+        Some(dirs) => {
+            for parent in &dirs {
+                for dir in std::fs::read_dir(parent).expect("Repo directory not found.") {
+                    let dir = dir.expect("Repo contains invalid entries");
+                    repo_descriptions.insert(GitsySettingsRepo {
+                        path: dir.path().clone(),
+                        ..Default::default()
+                    });
+                }
+            }
+        },
+        _ => {},
     }
 
-    let mut template_path = settings.template_dir.clone();
+    let mut template_path = settings.templates.path.clone();
     template_path.push("**");
     template_path.push("*.html");
     let mut tera = match Tera::new(template_path.to_str().expect("No template path set!")) {
@@ -635,34 +693,69 @@ fn main() {
     // Create output directory
     let _ = std::fs::create_dir(settings.output_dir.to_str().expect("Output path not set!"));
 
+    let generated_dt = chrono::offset::Local::now();
+
     let mut repos: Vec<GitRepo> = vec!();
-    for dir in std::fs::read_dir(std::path::Path::new("repos")).expect("Repo directory not found.") {
-        let dir = dir.expect("Repo contains invalid entries");
+    for repo_desc in &repo_descriptions {
+        let dir = &repo_desc.path;
         match dir.metadata() {
             Ok(m) if m.is_dir() => {},
             _ => continue,
         }
-        let path: String = dir.path().to_string_lossy().to_string();
-        let name: String = dir.file_name().to_string_lossy().to_string();
+        let path: String = dir.to_string_lossy().to_string();
+        let name: String = dir.file_name().expect("Encountered directory with no name!").to_string_lossy().to_string();
         let repo = Repository::open(path).expect("Unable to find git repository.");
-        let summary = parse_repo(&repo, &name).expect("Failed to analyze repo HEAD.");
+        let metadata = GitsyMetadata {
+            full_name: repo_desc.name.clone(),
+            description: repo_desc.description.clone(),
+            website: repo_desc.website.clone(),
+            clone: None,
+            attributes: repo_desc.attributes.clone(),
+        };
+        let summary = parse_repo(&repo, &name, metadata).expect("Failed to analyze repo HEAD.");
 
         let mut local_ctx = Context::from_serialize(&summary).unwrap();
-        match tera.render("summary.html", &local_ctx) {
+        if let Some(extra) = &settings.extra {
+            local_ctx.try_insert("extra", extra).expect("Failed to add extra settings to template engine.");
+        }
+        if let Some(site_name) = &settings.site_name {
+            local_ctx.insert("site_name", site_name);
+        }
+        if let Some(site_url) = &settings.site_url {
+            local_ctx.insert("site_url", site_url);
+        }
+        if let Some(site_description) = &settings.site_description {
+            local_ctx.insert("site_description", site_description);
+        }
+        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) => {
                 let mut output_path = settings.output_dir.clone();
                 output_path.push(&name);
                 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();
-                file.write(rendered.as_bytes()).expect("failed to save rendered html");
+                write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+            },
+            Err(x) => match x.kind {
+                tera::ErrorKind::TemplateNotFound(_) if settings.templates.repo_summary.is_none() => {},
+                _ => println!("ERROR: {:?}", x),
             },
-            Err(x) => println!("ERROR: {:?}", x),
         }
 
         for branch in &summary.branches {
             local_ctx.insert("branch", branch);
-            match tera.render("branch.html", &local_ctx) {
+            match tera.render(&settings.templates.branch.as_deref().unwrap_or("branch.html"), &local_ctx) {
                 Ok(rendered) => {
                     let mut output_path = settings.output_dir.clone();
                     output_path.push(&summary.name);
@@ -670,10 +763,10 @@ 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();
-                    file.write(rendered.as_bytes()).expect("failed to save rendered html");
+                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
                 },
                 Err(x) => match x.kind {
-                    tera::ErrorKind::TemplateNotFound(_) => {},
+                    tera::ErrorKind::TemplateNotFound(_) if settings.templates.branch.is_none() => {},
                     _ => println!("ERROR: {:?}", x),
                 },
             }
@@ -685,7 +778,7 @@ fn main() {
             if let Some(commit) = summary.commits.get(tag.tagged_id.as_ref().unwrap()) {
                 local_ctx.insert("commit", &commit);
             }
-            match tera.render("tag.html", &local_ctx) {
+            match tera.render(&settings.templates.tag.as_deref().unwrap_or("tag.html"), &local_ctx) {
                 Ok(rendered) => {
                     let mut output_path = settings.output_dir.clone();
                     output_path.push(&summary.name);
@@ -693,10 +786,10 @@ 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();
-                    file.write(rendered.as_bytes()).expect("failed to save rendered html");
+                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
                 },
                 Err(x) => match x.kind {
-                    tera::ErrorKind::TemplateNotFound(_) => {},
+                    tera::ErrorKind::TemplateNotFound(_) if settings.templates.tag.is_none() => {},
                     _ => println!("ERROR: {:?}", x),
                 },
             }
@@ -706,7 +799,7 @@ fn main() {
 
         for (_id, commit) in &summary.commits {
             local_ctx.try_insert("commit", &commit).expect("Failed to add commit to template engine.");
-            match tera.render("commit.html", &local_ctx) {
+            match tera.render(&settings.templates.commit.as_deref().unwrap_or("commit.html"), &local_ctx) {
                 Ok(rendered) => {
                     let mut output_path = settings.output_dir.clone();
                     output_path.push(&summary.name);
@@ -714,9 +807,12 @@ 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();
-                    file.write(rendered.as_bytes()).expect("failed to save rendered html");
+                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                },
+                Err(x) => match x.kind {
+                    tera::ErrorKind::TemplateNotFound(_) if settings.templates.commit.is_none() => {},
+                    _ => println!("ERROR: {:?}", x),
                 },
-                Err(x) => println!("ERROR: {:?}", x),
             }
             local_ctx.remove("commit");
         }
@@ -724,7 +820,7 @@ fn main() {
         for file in summary.all_files.iter().filter(|x| x.kind == "file") {
             let file = fill_file_contents(&repo, &file).expect("Failed to parse file.");
             local_ctx.try_insert("file", &file).expect("Failed to add file to template engine.");
-            match tera.render("file.html", &local_ctx) {
+            match tera.render(&settings.templates.file.as_deref().unwrap_or("file.html"), &local_ctx) {
                 Ok(rendered) => {
                     let mut output_path = settings.output_dir.clone();
                     output_path.push(&summary.name);
@@ -732,9 +828,12 @@ 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();
-                    file.write(rendered.as_bytes()).expect("failed to save rendered html");
+                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                },
+                Err(x) => match x.kind {
+                    tera::ErrorKind::TemplateNotFound(_) if settings.templates.file.is_none() => {},
+                    _ => println!("ERROR: {:?}", x),
                 },
-                Err(x) => println!("ERROR: {:?}", x),
             }
             local_ctx.remove("file");
         }
@@ -742,7 +841,7 @@ fn main() {
         for dir in summary.all_files.iter().filter(|x| x.kind == "dir") {
             let listing = dir_listing(&repo, &dir).expect("Failed to parse file.");
             local_ctx.try_insert("files", &listing).expect("Failed to add dir to template engine.");
-            match tera.render("dir.html", &local_ctx) {
+            match tera.render(&settings.templates.dir.as_deref().unwrap_or("dir.html"), &local_ctx) {
                 Ok(rendered) => {
                     let mut output_path = settings.output_dir.clone();
                     output_path.push(&summary.name);
@@ -750,9 +849,12 @@ 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();
-                    file.write(rendered.as_bytes()).expect("failed to save rendered html");
+                    write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+                },
+                Err(x) => match x.kind {
+                    tera::ErrorKind::TemplateNotFound(_) if settings.templates.dir.is_none() => {},
+                    _ => println!("ERROR: {:?}", x),
                 },
-                Err(x) => println!("ERROR: {:?}", x),
             }
             local_ctx.remove("files");
         }
@@ -762,13 +864,52 @@ fn main() {
 
     let mut global_ctx = Context::new();
     global_ctx.try_insert("repos", &repos).expect("Failed to add repo to template engine.");
-    match tera.render("repos.html", &global_ctx) {
+    if let Some(extra) = &settings.extra {
+        global_ctx.try_insert("extra", extra).expect("Failed to add extra settings to template engine.");
+    }
+    if let Some(site_name) = &settings.site_name {
+        global_ctx.insert("site_name", site_name);
+    }
+    if let Some(site_url) = &settings.site_url {
+        global_ctx.insert("site_url", site_url);
+    }
+    if let Some(site_description) = &settings.site_description {
+        global_ctx.insert("site_description", site_description);
+    }
+    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();
-            file.write(rendered.as_bytes()).expect("failed to save rendered html");
+            write_rendered(&mut file, &rendered, header.as_deref(), footer.as_deref());
+        },
+        Err(x) => match x.kind {
+            tera::ErrorKind::TemplateNotFound(_) if settings.templates.repo_list.is_none() => {},
+            _ => println!("ERROR: {:?}", x),
+        },
+    }
+
+    match tera.render(&settings.templates.error.as_deref().unwrap_or("404.html"), &global_ctx) {
+        Ok(rendered) => {
+            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());
+        },
+        Err(x) => match x.kind {
+            tera::ErrorKind::TemplateNotFound(_) if settings.templates.error.is_none() => {},
+            _ => println!("ERROR: {:?}", x),
         },
-        Err(x) => println!("ERROR: {:?}", x),
     }
 }

diff --git a/templates/404.html b/templates/404.html
line changes: +1/-0
index 0000000..bb95dd8
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1 @@
+The page you are seeking does not exist.

diff --git a/templates/branch.html b/templates/branch.html
line changes: +7/-11
index f979a62..d349372
--- a/templates/branch.html
+++ b/templates/branch.html
@@ -1,11 +1,7 @@
-<html>
-  <body style="font-family: monospace;">
-    branch: {{branch.ref_name}}<br/>
-    hash: {{branch.full_hash}} ({{branch.short_hash}})<br/>
-    author: {{branch.author.name}}<br/>
-    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>
-  </body>
-</html>
+branch: {{branch.ref_name}}<br/>
+hash: {{branch.full_hash}} ({{branch.short_hash}})<br/>
+author: {{branch.author.name}}<br/>
+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>

diff --git a/templates/commit.html b/templates/commit.html
line changes: +25/-29
index c216f9c..c399d17
--- a/templates/commit.html
+++ b/templates/commit.html
@@ -1,31 +1,27 @@
-<html>
-  <body style="font-family: monospace;">
-    <strong>
-    commit: {{commit.full_hash}}<br/>
-    author: {{commit.author.name}}<br/>
-    committer: {{commit.committer.name}}<br/>
-    parent: {% if commit.parents | length > 0 -%}<a href="{{commit.parents | first}}.html">{{commit.parents | first}}</a>{%-endif-%}<br/>
-    </strong>
-    <br/>
-    <pre style="margin: 0;">{{commit.message}}</pre>
-    {% for file in commit.diff.files -%}
-    <br/>
-    <strong>
-    diff --git a/{{file.basefile}} b/{{file.basefile}}<br/>
-    line changes: +{{file.additions}}/-{{file.deletions}}<br/>
-    index {{file.oldid | truncate(length=7,end="")}}..{{file.newid | truncate(length=7,end="")}}<br/>
-    --- {{file.oldfile}}<br/>
-    +++ {{file.newfile}}
-    </strong>
-    {% for hunk in file.hunks -%}
-    <pre><strong>{{hunk.context}}</strong>
-    {%- for line in hunk.lines -%}
-    {%- if line.kind in ["del","add"] -%}<span style="color: {%- if line.kind == "del" -%}bb0000{%- else -%}00aa00{% endif %}">{%- endif -%}
+<strong>
+  commit: {{commit.full_hash}}<br/>
+  author: {{commit.author.name}}<br/>
+  committer: {{commit.committer.name}}<br/>
+  parent: {% if commit.parents | length > 0 -%}<a href="{{commit.parents | first}}.html">{{commit.parents | first}}</a>{%-endif-%}<br/>
+</strong>
+<br/>
+<pre style="margin: 0;">{{commit.message}}</pre>
+{% for file in commit.diff.files -%}
+<br/>
+<strong>
+  diff --git a/{{file.basefile}} b/{{file.basefile}}<br/>
+  line changes: +{{file.additions}}/-{{file.deletions}}<br/>
+  index {{file.oldid | truncate(length=7,end="")}}..{{file.newid | truncate(length=7,end="")}}<br/>
+  --- {{file.oldfile}}<br/>
+  +++ {{file.newfile}}
+</strong>
+{% for hunk in file.hunks -%}
+<pre><strong>{{hunk.context}}</strong>
+  {%- for line in hunk.lines -%}
+  {%- if line.kind in ["del","add"] -%}<span style="color: {%- if line.kind == "del" -%}bb0000{%- else -%}00aa00{% endif %}">{%- endif -%}
     {{line.prefix}}{{line.text}}
     {%- if line.kind in ["del","add"] -%}</span>{%- endif -%}
-    {%- endfor -%}
-    </pre>
-    {% endfor -%}
-    {% endfor -%}
-</body>
-</html>
+  {%- endfor -%}
+</pre>
+{% endfor -%}
+{% endfor -%}

diff --git a/templates/dir.html b/templates/dir.html
line changes: +17/-21
index 10a0eb3..44afb19
--- a/templates/dir.html
+++ b/templates/dir.html
@@ -1,21 +1,17 @@
-<html>
-  <body style="font-family: monospace;">
-    <table class="files">
-      <tr>
-        <th>File</th>
-        <th>ID</th>
-        <th>Type</th>
-        <th>Mode</th>
-        <th>Size</th>
-      </tr>
-    {% for file in files -%}
-      <tr class="file">
-        <td class="file-name"><a href="../{{file.kind}}/{{file.id}}.html">{{file.name}}</a></td>
-        <td class="file-id">{{file.id}}</td>
-        <td class="file-type">{{file.kind}} ({{file.is_binary}})</td>
-        <td class="file-mode">{{file.mode}}</td>
-        <td class="file-size">{{file.size}}</td>
-      </tr>
-    {% endfor -%}
-  </body>
-</html>
+<table class="files">
+  <tr>
+    <th>File</th>
+    <th>ID</th>
+    <th>Type</th>
+    <th>Mode</th>
+    <th>Size</th>
+  </tr>
+  {% for file in files -%}
+  <tr class="file">
+    <td class="file-name"><a href="../{{file.kind}}/{{file.id}}.html">{{file.name}}</a></td>
+    <td class="file-id">{{file.id}}</td>
+    <td class="file-type">{{file.kind}} ({{file.is_binary}})</td>
+    <td class="file-mode">{{file.mode}}</td>
+    <td class="file-size">{{file.size}}</td>
+  </tr>
+  {% endfor -%}

diff --git a/templates/file.html b/templates/file.html
line changes: +3/-7
index 762aa06..7f00ee5
--- a/templates/file.html
+++ b/templates/file.html
@@ -1,7 +1,3 @@
-<html>
-  <body style="font-family: monospace;">
-    {{file.path}} ({{file.name}}) [{{file.id}}]<br/>
-    -------
-    <pre style="margin: 0">{{file.contents}}</pre>
-  </body>
-</html>
+{{file.path}} ({{file.name}}) [{{file.id}}]<br/>
+-------
+<pre style="margin: 0">{{file.contents}}</pre>

diff --git a/templates/footer.html b/templates/footer.html
line changes: +2/-0
index 0000000..b605728
--- /dev/null
+++ b/templates/footer.html
@@ -0,0 +1,2 @@
+  </body>
+</html>

diff --git a/templates/header.html b/templates/header.html
line changes: +9/-0
index 0000000..6beb4f4
--- /dev/null
+++ b/templates/header.html
@@ -0,0 +1,9 @@
+<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/>

diff --git a/templates/repos.html b/templates/repos.html
line changes: +3/-7
index 1ce812d..693da4f
--- a/templates/repos.html
+++ b/templates/repos.html
@@ -1,7 +1,3 @@
-<html>
-  <body style="font-family: monospace;">
-    {% for repo in repos -%}
-    <a href="{{repo.name}}/summary.html">{{ repo.name }}</a> ({{ts_to_date(ts=repo.history[0].ts_utc, tz=repo.history[0].ts_offset)}})<br/>
-    {% endfor -%}
-  </body>
-</html>
+{% 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 -%}

diff --git a/templates/summary.html b/templates/summary.html
line changes: +76/-80
index 70377f3..dcc8b6a
--- a/templates/summary.html
+++ b/templates/summary.html
@@ -1,83 +1,79 @@
-<html>
-  <body style="font-family: monospace;">
-    <table class="commits">
-      <tr>
-        <th>Commit ID</th>
-        <th>Message</th>
-        <th>Author</th>
-        <th>Date</th>
-        <th>Diff</th>
-        <th>Refs</th>
-      </tr>
-    {% for entry in history -%}
-    {% if loop.index0 < 250  -%}
-      <tr class="commit">
-        <td class="oid"><a href="commit/{{entry.full_hash}}.html">{{entry.short_hash}}</a></td>
-        <td class="commit-msg" style="font-family: sans-serif;">{{entry.summary}}</td>
-        <td class="author" style="font-family: sans-serif;">{{entry.author.name}}</td>
-        <td class="date">{{ts_to_date(ts=entry.ts_utc, tz=entry.ts_offset)}}</td>
-        <td class="diff">{{entry.stats.files}} (+{{entry.stats.additions}}/-{{entry.stats.deletions}})</td>
-        <td class="refs">{%- for ref in entry.alt_refs -%}{%- if loop.index0 > 0 -%},&nbsp; {%- endif -%}<span class="commit-ref">{{ref}}</span>{%- endfor -%}</td>
-      </tr>
-    {% endif -%}
-    {% endfor -%}
-    </table>
+<table class="commits">
+  <tr>
+    <th>Commit ID</th>
+    <th>Message</th>
+    <th>Author</th>
+    <th>Date</th>
+    <th>Diff</th>
+    <th>Refs</th>
+  </tr>
+  {% for entry in history -%}
+  {% if loop.index0 < 250  -%}
+                      <tr class="commit">
+                        <td class="oid"><a href="commit/{{entry.full_hash}}.html">{{entry.short_hash}}</a></td>
+                        <td class="commit-msg" style="font-family: sans-serif;">{{entry.summary}}</td>
+                        <td class="author" style="font-family: sans-serif;">{{entry.author.name}}</td>
+                        <td class="date">{{ts_to_date(ts=entry.ts_utc, tz=entry.ts_offset)}}</td>
+                        <td class="diff">{{entry.stats.files}} (+{{entry.stats.additions}}/-{{entry.stats.deletions}})</td>
+                        <td class="refs">{%- for ref in entry.alt_refs -%}{%- if loop.index0 > 0 -%},&nbsp; {%- endif -%}<span class="commit-ref">{{ref}}</span>{%- endfor -%}</td>
+</tr>
+{% endif -%}
+{% endfor -%}
+</table>
 
-    <table class="branches">
-      <tr>
-        <th>Branch</th>
-        <th>Commit ID</th>
-        <th>Message</th>
-        <th>Author</th>
-        <th>Date</th>
-      </tr>
-    {% for entry in branches -%}
-      <tr class="branch">
-        <td class="name"><a href="branch/{{entry.full_hash}}.html">{{entry.ref_name}}</a></td>
-        <td class="oid">{{entry.short_hash}}</td>
-        <td class="commit-msg" style="font-family: sans-serif;">{{entry.summary}}</td>
-        <td class="author">{{entry.author.name}}</td>
-        <td class="date">{{ts_to_date(ts=entry.ts_utc, tz=entry.ts_offset)}}</td>
-      </tr>
-    {% endfor -%}
-    </table>
+<table class="branches">
+  <tr>
+    <th>Branch</th>
+    <th>Commit ID</th>
+    <th>Message</th>
+    <th>Author</th>
+    <th>Date</th>
+  </tr>
+  {% for entry in branches -%}
+  <tr class="branch">
+    <td class="name"><a href="branch/{{entry.full_hash}}.html">{{entry.ref_name}}</a></td>
+    <td class="oid">{{entry.short_hash}}</td>
+    <td class="commit-msg" style="font-family: sans-serif;">{{entry.summary}}</td>
+    <td class="author">{{entry.author.name}}</td>
+    <td class="date">{{ts_to_date(ts=entry.ts_utc, tz=entry.ts_offset)}}</td>
+  </tr>
+  {% endfor -%}
+</table>
 
-    <table class="tags">
-      <tr>
-        <th>Tag</th>
-        <th>Commit ID</th>
-        <th>Message</th>
-        <th>Author</th>
-        <th>Date</th>
-      </tr>
-    {% for entry in tags -%}
-      <tr class="tag">
-        <td class="name"><a href="tag/{{entry.full_hash}}.html">{{entry.ref_name}}</a></td>
-        <td class="oid">{{entry.short_hash}}</td>
-        <td class="commit-msg" style="font-family: sans-serif;">{{entry.summary}}</td>
-        <td class="author">{{entry.author.name}}</td>
-        <td class="date">{{ts_to_date(ts=entry.ts_utc, tz=entry.ts_offset)}}</td>
-      </tr>
-    {% endfor -%}
-    </table>
+<table class="tags">
+  <tr>
+    <th>Tag</th>
+    <th>Commit ID</th>
+    <th>Message</th>
+    <th>Author</th>
+    <th>Date</th>
+  </tr>
+  {% for entry in tags -%}
+  <tr class="tag">
+    <td class="name"><a href="tag/{{entry.full_hash}}.html">{{entry.ref_name}}</a></td>
+    <td class="oid">{{entry.short_hash}}</td>
+    <td class="commit-msg" style="font-family: sans-serif;">{{entry.summary}}</td>
+    <td class="author">{{entry.author.name}}</td>
+    <td class="date">{{ts_to_date(ts=entry.ts_utc, tz=entry.ts_offset)}}</td>
+  </tr>
+  {% endfor -%}
+</table>
 
-    <table class="files">
-      <tr>
-        <th>File</th>
-        <th>ID</th>
-        <th>Type</th>
-        <th>Mode</th>
-        <th>Size</th>
-      </tr>
-    {% for file in root_files -%}
-      <tr class="file">
-        <td class="name"><a href="{{file.kind}}/{{file.id}}.html">{{file.name}}</a></td>
-        <td class="id">{{file.id}}</td>
-        <td class="type">{{file.kind}} ({{file.is_binary}})</td>
-        <td class="mode">{{file.mode}}</td>
-        <td class="size">{{file.size}}</td>
-      </tr>
-    {% endfor -%}
-    </table>
-</body>
-</html>
+<table class="files">
+  <tr>
+    <th>File</th>
+    <th>ID</th>
+    <th>Type</th>
+    <th>Mode</th>
+    <th>Size</th>
+  </tr>
+  {% for file in root_files -%}
+  <tr class="file">
+    <td class="name"><a href="{{file.kind}}/{{file.id}}.html">{{file.name}}</a></td>
+    <td class="id">{{file.id}}</td>
+    <td class="type">{{file.kind}} ({{file.is_binary}})</td>
+    <td class="mode">{{file.mode}}</td>
+    <td class="size">{{file.size}}</td>
+  </tr>
+  {% endfor -%}
+</table>

diff --git a/templates/tag.html b/templates/tag.html
line changes: +11/-15
index 53bf17e..df2ff89
--- a/templates/tag.html
+++ b/templates/tag.html
@@ -1,15 +1,11 @@
-<html>
-  <body style="font-family: monospace;">
-    branch: {{tag.ref_name}}<br/>
-    hash: {{tag.full_hash}} ({{tag.short_hash}})<br/>
-    author: {{tag.author.name}}<br/>
-    committer: {{tag.committer.name}}<br/>
-    date: {{ts_to_date(ts=tag.ts_utc, tz=tag.ts_offset)}}<br/>
-    summary: {{tag.summary}}<br/>
-    <pre>{{tag.message}}</pre>
-    <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/>
-  </body>
-</html>
+branch: {{tag.ref_name}}<br/>
+hash: {{tag.full_hash}} ({{tag.short_hash}})<br/>
+author: {{tag.author.name}}<br/>
+committer: {{tag.committer.name}}<br/>
+date: {{ts_to_date(ts=tag.ts_utc, tz=tag.ts_offset)}}<br/>
+summary: {{tag.summary}}<br/>
+<pre>{{tag.message}}</pre>
+<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/>