stagen - static git site generator
Files | Log | Commits | Refs | README
Size: 8806 bytes
/* 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)
}