stagen

stagen - static git site generator

Files | Log | Commits | Refs | README


stagen.go

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)
}