stagen - static git site generator
Files | Log | Commits | Refs | README
Author: SM
Date: 2025-08-28
Subject: initial commit
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) +}