stagen

stagen - static git site generator

Files | Log | Commits | Refs | README


a61f68d

Author: SM

Date: 2025-08-28

Subject: initial commit

Diff

commit a61f68df0adf09cecec7c8570fcb169d165f85b7
Author: SM <seb.michalk@gmail.com>
Date:   Thu Aug 28 17:18:57 2025 +0200

    initial commit

diff --git a/.README.md.swp b/.README.md.swp
new file mode 100644
index 0000000..15c4eb1
Binary files /dev/null and b/.README.md.swp differ
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..972dba4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+# stagen
+
+static git web viewer. Generates static HTML pages for git repositories.
+
+## Usage
+
+Generate static pages for a repository:
+```
+./stagen -repo /path/to/git/repo -out repos/myrepo -name "My Repo" -desc "Description"
+```
+
+After generating repos, your directory structure will look like:
+```
+├── repos/
+│   ├── index.html          # Main repository index
+│   ├── style.css           # Shared CSS file
+│   ├── myrepo/
+│   │   ├── index.html      # Repository files view
+│   │   ├── log.html        # Commit log
+│   │   ├── commits.html    # Commits table
+│   │   ├── refs.html       # Branches/tags
+│   │   ├── readme.html     # README display
+│   │   ├── file/           # Individual files
+│   │   └── commit/         # Individual commits
+│   └── another/
+│       └── ...
+├── stagen                  # Bin
+└── *.tmpl                  # Template files
+```
+
+## Features
+
+- Single file implementation
+- No dependencies beyond Go stdlib
+- Diff syntax highlighting
+- Multiple repository index
+- Uses Go's template approach
+
+## Build
+
+```
+go build stagen.go
+```
diff --git a/commit.tmpl b/commit.tmpl
new file mode 100644
index 0000000..7ff2826
--- /dev/null
+++ b/commit.tmpl
@@ -0,0 +1,9 @@
+{{template "header.tmpl" .}}
+<h2>{{.ShortHash}}</h2>
+<p><strong>Author:</strong> {{.Author}}</p>
+<p><strong>Date:</strong> {{.Date}}</p>
+<p><strong>Subject:</strong> {{.Subject}}</p>
+{{if .Body}}<p><strong>Body:</strong> {{.Body}}</p>{{end}}
+<h3>Diff</h3>
+<pre>{{.Diff}}</pre>
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/commits.tmpl b/commits.tmpl
new file mode 100644
index 0000000..0101256
--- /dev/null
+++ b/commits.tmpl
@@ -0,0 +1,9 @@
+{{template "header.tmpl" .}}
+<h2>Commits</h2>
+<table>
+<tr><th>Hash</th><th>Author</th><th>Date</th><th>Subject</th></tr>
+{{range .Commits}}
+<tr><td><a href="commit/{{.Hash}}.html">{{.ShortHash}}</a></td><td>{{.Author}}</td><td>{{.Date}}</td><td>{{.Subject}}</td></tr>
+{{end}}
+</table>
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/file.tmpl b/file.tmpl
new file mode 100644
index 0000000..e3162b7
--- /dev/null
+++ b/file.tmpl
@@ -0,0 +1,5 @@
+{{template "header.tmpl" .}}
+<h2>{{.Path}}</h2>
+<p><strong>Size:</strong> {{.Size}} bytes</p>
+<pre>{{.Content}}</pre>
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/footer.tmpl b/footer.tmpl
new file mode 100644
index 0000000..691287b
--- /dev/null
+++ b/footer.tmpl
@@ -0,0 +1,2 @@
+</body>
+</html>
\ No newline at end of file
diff --git a/header.tmpl b/header.tmpl
new file mode 100644
index 0000000..4bbdf7c
--- /dev/null
+++ b/header.tmpl
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>{{.Title}} - {{.Name}}</title>
+<link rel="stylesheet" href="{{.StylePath}}">
+</head>
+<body>
+<h1>{{.Name}}</h1>
+<p>{{.Desc}}</p>
+<p><a href="{{.BasePath}}index.html">Files</a> | <a href="{{.BasePath}}log.html">Log</a> | <a href="{{.BasePath}}commits.html">Commits</a> | <a href="{{.BasePath}}refs.html">Refs</a> | <a href="{{.BasePath}}readme.html">README</a></p>
+<hr>
\ No newline at end of file
diff --git a/index.tmpl b/index.tmpl
new file mode 100644
index 0000000..544aa69
--- /dev/null
+++ b/index.tmpl
@@ -0,0 +1,9 @@
+{{template "header.tmpl" .}}
+<h2>Files</h2>
+<p><strong>Last commit:</strong> {{.LastCommit}}</p>
+<table>
+{{range .Files}}
+<tr><td>{{.Mode}}</td><td><a href="file/{{.Path}}.html">{{.Path}}</a></td><td>{{.Size}}</td></tr>
+{{end}}
+</table>
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/log.tmpl b/log.tmpl
new file mode 100644
index 0000000..02cb3dc
--- /dev/null
+++ b/log.tmpl
@@ -0,0 +1,10 @@
+{{template "header.tmpl" .}}
+<h2>Log</h2>
+{{range .Commits}}
+<div>
+<p><strong><a href="commit/{{.Hash}}.html">{{.ShortHash}}</a></strong> {{.Subject}}</p>
+<p>{{.Author}} - {{.Date}}</p>
+</div>
+<hr>
+{{end}}
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/main-index.tmpl b/main-index.tmpl
new file mode 100644
index 0000000..9ac24ad
--- /dev/null
+++ b/main-index.tmpl
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Git Repositories</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+<body>
+<h1>Git Repositories</h1>
+<table>
+<tr><th>Name</th><th>Description</th><th>Last Updated</th></tr>
+{{range .Repos}}
+<tr><td><a href="{{.Dir}}/index.html">{{.Name}}</a></td><td>{{.Desc}}</td><td>{{.LastCommit}}</td></tr>
+{{end}}
+</table>
+</body>
+</html>
\ No newline at end of file
diff --git a/readme.tmpl b/readme.tmpl
new file mode 100644
index 0000000..a10f0d6
--- /dev/null
+++ b/readme.tmpl
@@ -0,0 +1,8 @@
+{{template "header.tmpl" .}}
+<h2>README</h2>
+{{if .ReadmeContent}}
+<pre>{{.ReadmeContent}}</pre>
+{{else}}
+<p>No README file found.</p>
+{{end}}
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/refs.tmpl b/refs.tmpl
new file mode 100644
index 0000000..51a46fc
--- /dev/null
+++ b/refs.tmpl
@@ -0,0 +1,9 @@
+{{template "header.tmpl" .}}
+<h2>Refs</h2>
+<table>
+<tr><th>Name</th><th>Type</th><th>Hash</th></tr>
+{{range .Refs}}
+<tr><td>{{.Name}}</td><td>{{.Type}}</td><td>{{.Hash}}</td></tr>
+{{end}}
+</table>
+{{template "footer.tmpl"}}
\ No newline at end of file
diff --git a/stagen.go b/stagen.go
new file mode 100644
index 0000000..938cb7b
--- /dev/null
+++ b/stagen.go
@@ -0,0 +1,389 @@
+/* MIT/X Consortium License
+
+ (c) 2025 sebastian <sm@secpaste.dev>
+
+ Permission is hereby granted, free of charge, to any person obtaining a
+ copy of this software and associated documentation files (the "Software"),
+ to deal in the Software without restriction, including without limitation
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ and/or sell copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ DEALINGS IN THE SOFTWARE. */
+
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"html"
+	"html/template"
+	"io/ioutil"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+type Args struct {
+	repo    string
+	out     string
+	name    string
+	desc    string
+	url     string
+}
+
+type Repo struct {
+	Name          string
+	Desc          string
+	Url           string
+	LastCommit    string
+	Files         []File
+	Commits       []Commit
+	Refs          []Ref
+	Title         string
+	StylePath     string
+	BasePath      string
+	ReadmeContent string
+	Dir           string
+}
+
+type MainIndex struct {
+	Repos []Repo
+}
+
+type File struct {
+	FileName string
+	Path     string
+	Size     string
+	Mode     string
+}
+
+type Commit struct {
+	Hash      string
+	ShortHash string
+	Author    string
+	Date      string
+	Subject   string
+	Body      string
+	Files     []string
+	Stats     string
+}
+
+type Ref struct {
+	Name string
+	Hash string
+	Type string
+}
+
+func die(err error) {
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run(cmd string, args ...string) string {
+	out, err := exec.Command(cmd, args...).Output()
+	die(err)
+	return strings.TrimSpace(string(out))
+}
+
+func runInDir(dir, cmd string, args ...string) string {
+	c := exec.Command(cmd, args...)
+	c.Dir = dir
+	out, err := c.Output()
+	die(err)
+	return strings.TrimSpace(string(out))
+}
+
+func getCommits(repo string) []Commit {
+	lines := strings.Split(runInDir(repo, "git", "log", "--format=%H|%h|%an|%ad|%s|%b", "--date=short", "-n", "50"), "\n")
+	commits := make([]Commit, 0)
+	
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Split(line, "|")
+		if len(parts) < 5 {
+			continue
+		}
+		
+		hash := parts[0]
+		stats := runInDir(repo, "git", "show", "--stat", "--format=", hash)
+		files := strings.Split(runInDir(repo, "git", "show", "--name-only", "--format=", hash), "\n")
+		
+		commit := Commit{
+			Hash:      hash,
+			ShortHash: parts[1],
+			Author:    parts[2],
+			Date:      parts[3],
+			Subject:   parts[4],
+			Body:      strings.Join(parts[5:], "|"),
+			Files:     files,
+			Stats:     stats,
+		}
+		commits = append(commits, commit)
+	}
+	return commits
+}
+
+func getFiles(repo string) []File {
+	lines := strings.Split(runInDir(repo, "git", "ls-tree", "-r", "-l", "HEAD"), "\n")
+	files := make([]File, 0)
+	
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Fields(line)
+		if len(parts) < 4 {
+			continue
+		}
+		
+		size := parts[3]
+		if parts[1] == "tree" {
+			size = "-"
+		}
+		
+		file := File{
+			Mode:     parts[0],
+			FileName: filepath.Base(parts[4]),
+			Path:     parts[4],
+			Size:     size,
+		}
+		files = append(files, file)
+	}
+	
+	sort.Slice(files, func(i, j int) bool {
+		return files[i].Path < files[j].Path
+	})
+	
+	return files
+}
+
+func getRefs(repo string) []Ref {
+	lines := strings.Split(runInDir(repo, "git", "for-each-ref", "--format=%(refname:short)|%(objectname)|%(objecttype)"), "\n")
+	refs := make([]Ref, 0)
+	
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Split(line, "|")
+		if len(parts) < 3 {
+			continue
+		}
+		
+		ref := Ref{
+			Name: parts[0],
+			Hash: parts[1],
+			Type: parts[2],
+		}
+		refs = append(refs, ref)
+	}
+	return refs
+}
+
+func getLastCommit(repo string) string {
+	return runInDir(repo, "git", "log", "-1", "--format=%ad", "--date=short")
+}
+
+func getReadme(repo string) string {
+	readmeFiles := []string{"README.md", "README.txt", "README", "readme.md", "readme.txt", "readme"}
+	for _, filename := range readmeFiles {
+		c := exec.Command("git", "show", "HEAD:"+filename)
+		c.Dir = repo
+		out, err := c.Output()
+		if err == nil && len(out) > 0 {
+			return strings.TrimSpace(string(out))
+		}
+	}
+	return ""
+}
+
+func mkRepo(args Args) Repo {
+	return Repo{
+		Name:          args.name,
+		Desc:          args.desc,
+		Url:           args.url,
+		LastCommit:    getLastCommit(args.repo),
+		Files:         getFiles(args.repo),
+		Commits:       getCommits(args.repo),
+		Refs:          getRefs(args.repo),
+		Title:         "",
+		StylePath:     "/style.css",
+		BasePath:      "",
+		ReadmeContent: getReadme(args.repo),
+		Dir:           filepath.Base(args.out),
+	}
+}
+
+func writeFile(path string, tmplFile string, data interface{}) {
+	os.MkdirAll(filepath.Dir(path), 0755)
+	
+	t := template.Must(template.ParseGlob("*.tmpl"))
+	
+	f, err := os.Create(path)
+	die(err)
+	defer f.Close()
+	
+	die(t.ExecuteTemplate(f, tmplFile, data))
+}
+
+func genIndex(args Args, repo Repo) {
+	repo.Title = "Files"
+	writeFile(args.out+"/index.html", "index.tmpl", repo)
+}
+
+func genLog(args Args, repo Repo) {
+	repo.Title = "Log"
+	writeFile(args.out+"/log.html", "log.tmpl", repo)
+}
+
+func genCommits(args Args, repo Repo) {
+	repo.Title = "Commits"
+	writeFile(args.out+"/commits.html", "commits.tmpl", repo)
+}
+
+func genRefs(args Args, repo Repo) {
+	repo.Title = "Refs"
+	writeFile(args.out+"/refs.html", "refs.tmpl", repo)
+}
+
+func genReadme(args Args, repo Repo) {
+	repo.Title = "README"
+	writeFile(args.out+"/readme.html", "readme.tmpl", repo)
+}
+
+func genFilePages(args Args, repo Repo) {
+	for _, file := range repo.Files {
+		content := runInDir(args.repo, "git", "show", "HEAD:"+file.Path)
+		
+		depth := strings.Count(file.Path, "/") + 1
+		basePath := strings.Repeat("../", depth)
+		
+		data := struct {
+			Repo
+			File
+			Content string
+		}{repo, file, content}
+		data.Title = file.Path
+		data.StylePath = "/style.css"
+		data.BasePath = basePath
+		
+		writeFile(args.out+"/file/"+file.Path+".html", "file.tmpl", data)
+	}
+}
+
+func hlDiff(diff string) string {
+	lines := strings.Split(diff, "\n")
+	for i, line := range lines {
+		if len(line) == 0 {
+			continue
+		}
+		escaped := html.EscapeString(line)
+		switch line[0] {
+		case '+':
+			lines[i] = `<span class="i">` + escaped + `</span>`
+		case '-':
+			lines[i] = `<span class="d">` + escaped + `</span>`
+		default:
+			lines[i] = escaped
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+func genCommitPages(args Args, repo Repo) {
+	for _, commit := range repo.Commits {
+		diff := runInDir(args.repo, "git", "show", commit.Hash)
+		
+		data := struct {
+			Repo
+			Commit
+			Diff template.HTML
+		}{repo, commit, template.HTML(hlDiff(diff))}
+		data.Title = commit.ShortHash
+		data.StylePath = "/style.css"
+		data.BasePath = "../"
+		
+		writeFile(args.out+"/commit/"+commit.Hash+".html", "commit.tmpl", data)
+	}
+}
+
+func updateMainIndex(args Args, repo Repo) {
+	parentDir := filepath.Dir(args.out)
+	indexPath := parentDir + "/index.json"
+	
+	var repos []Repo
+	data, err := ioutil.ReadFile(indexPath)
+	if err == nil {
+		json.Unmarshal(data, &repos)
+	}
+	
+	for i, r := range repos {
+		if r.Dir == repo.Dir {
+			repos[i] = repo
+			goto write
+		}
+	}
+	repos = append(repos, repo)
+	
+write:
+	data, _ = json.Marshal(repos)
+	ioutil.WriteFile(indexPath, data, 0644)
+	
+	t := template.Must(template.ParseFiles("main-index.tmpl"))
+	f, err := os.Create(parentDir + "/index.html")
+	if err != nil {
+		return
+	}
+	defer f.Close()
+	
+	os.Rename("style.css", parentDir+"/style.css")
+	t.Execute(f, MainIndex{repos})
+}
+
+func main() {
+	repo := flag.String("repo", "", "git repository path (required)")
+	out := flag.String("out", "", "output directory (required)")
+	name := flag.String("name", "", "repository name (required)")
+	desc := flag.String("desc", "", "repository description")
+	url := flag.String("url", "", "repository url")
+	flag.Parse()
+	
+	if *repo == "" || *out == "" || *name == "" {
+		log.Fatal("repo, out and name are required")
+	}
+	
+	args := Args{*repo, *out, *name, *desc, *url}
+	r := mkRepo(args)
+	
+	os.RemoveAll(*out)
+	os.MkdirAll(*out, 0755)
+	
+	genIndex(args, r)
+	genLog(args, r)
+	genCommits(args, r)
+	genRefs(args, r)
+	genReadme(args, r)
+	genFilePages(args, r)
+	genCommitPages(args, r)
+	
+	updateMainIndex(args, r)
+	
+	fmt.Printf("Generated static git viewer in %s\n", *out)
+}