internal/render/render.go
raw ยท 10685 bytes
package render
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/tgckpg/flatgit/internal/config"
"github.com/tgckpg/flatgit/internal/gitcmd"
)
const maxBlobHTMLBytes = 512 * 1024
type Renderer struct {
Git *gitcmd.Runner
PublicURL string
MaxCommits int
}
type RefInfo struct {
Name string `json:"name"`
Short string `json:"short"`
Kind string `json:"kind"`
Commit string `json:"commit"`
}
type CommitInfo struct {
Hash string `json:"hash"`
Short string `json:"short"`
Author string `json:"author"`
Email string `json:"email,omitempty"`
Date string `json:"date"`
Subject string `json:"subject"`
}
type TreeEntry struct {
Mode string `json:"mode"`
Type string `json:"type"`
Hash string `json:"hash"`
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
}
func New(git *gitcmd.Runner, publicURL string, maxCommits int) *Renderer {
if maxCommits <= 0 {
maxCommits = 500
}
return &Renderer{Git: git, PublicURL: publicURL, MaxCommits: maxCommits}
}
func (r *Renderer) RenderRepo(ctx context.Context, repo config.Repo) error {
if !isSafeOutput(repo.OutputDir) {
return fmt.Errorf("unsafe output dir %q", repo.OutputDir)
}
commit, err := r.resolveDefaultCommit(ctx, repo)
if err != nil {
return err
}
refs, err := r.refs(ctx, repo)
if err != nil {
return err
}
commits, err := r.commits(ctx, repo, commit)
if err != nil {
return err
}
tree, err := r.tree(ctx, repo, commit)
if err != nil {
return err
}
next := repo.OutputDir + ".next"
old := repo.OutputDir + ".old"
_ = os.RemoveAll(next)
_ = os.RemoveAll(old)
if err := os.MkdirAll(next, 0o755); err != nil {
return err
}
if err := writeStatic(next); err != nil {
return err
}
manifest := NewManifest(repo, repo.DefaultBranch, commit)
if err := writeJSON(filepath.Join(next, "manifest.json"), manifest); err != nil {
return err
}
if err := writeJSON(filepath.Join(next, "refs.json"), refs); err != nil {
return err
}
if err := writeJSON(filepath.Join(next, "commits.json"), commits); err != nil {
return err
}
if err := writeJSON(filepath.Join(next, "tree.json"), tree); err != nil {
return err
}
page := basePage{RepoManifest: manifest, GeneratedAt: manifest.GeneratedAt.Format(time.RFC3339)}
if err := renderTemplate(filepath.Join(next, "index.html"), indexTemplate, struct {
basePage
Refs []RefInfo
Commits []CommitInfo
Files []TreeEntry
}{page, refs, firstCommits(commits, 20), firstTree(tree, 50)}); err != nil {
return err
}
if err := renderTemplate(filepath.Join(next, "refs.html"), refsTemplate, struct {
basePage
Refs []RefInfo
}{page, refs}); err != nil {
return err
}
if err := renderTemplate(filepath.Join(next, "log.html"), logTemplate, struct {
basePage
Commits []CommitInfo
}{page, commits}); err != nil {
return err
}
branchSlug := refSlug(repo.DefaultBranch)
if err := renderTemplate(filepath.Join(next, "tree", branchSlug, "index.html"), treeTemplate, struct {
basePage
Ref string
Files []TreeEntry
}{page, repo.DefaultBranch, tree}); err != nil {
return err
}
for _, c := range commits {
show, err := r.Git.Text(ctx, repo.MirrorDir, "show", "--date=iso-strict-local", "--stat", "--patch", "--find-renames", "--no-ext-diff", "--no-color", c.Hash)
if err != nil {
return err
}
if err := renderTemplate(filepath.Join(next, "commit", c.Hash+".html"), commitTemplate, struct {
basePage
Commit CommitInfo
Show string
}{page, c, show}); err != nil {
return err
}
if err := writeJSON(filepath.Join(next, "commit", c.Hash+".json"), c); err != nil {
return err
}
}
for _, e := range tree {
if e.Type != "blob" {
continue
}
content, err := r.Git.Output(ctx, repo.MirrorDir, "show", "--date=iso-strict-local", commit+":"+e.Path)
if err != nil {
return err
}
rawPath := filepath.Join(next, "raw", branchSlug, filepath.FromSlash(e.Path))
if err := writeFile(rawPath, content, 0o644); err != nil {
return err
}
blobPath := filepath.Join(next, "blob", branchSlug, filepath.FromSlash(e.Path)+".html")
blob := blobView{Path: e.Path, Size: int64(len(content)), RawHref: relPath(filepath.Dir(blobPath), rawPath)}
if len(content) > maxBlobHTMLBytes || bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) {
blob.Binary = true
} else {
blob.Text = string(content)
}
if err := renderTemplate(blobPath, blobTemplate, struct {
basePage
Blob blobView
}{page, blob}); err != nil {
return err
}
}
if err := publish(next, repo.OutputDir, old); err != nil {
return err
}
return nil
}
func (r *Renderer) resolveDefaultCommit(ctx context.Context, repo config.Repo) (string, error) {
candidates := []string{"refs/heads/" + repo.DefaultBranch, repo.DefaultBranch, "HEAD"}
var last error
for _, ref := range candidates {
out, err := r.Git.Text(ctx, repo.MirrorDir, "rev-parse", "--verify", ref)
if err == nil {
return strings.TrimSpace(out), nil
}
last = err
}
return "", last
}
func (r *Renderer) refs(ctx context.Context, repo config.Repo) ([]RefInfo, error) {
out, err := r.Git.Output(ctx, repo.MirrorDir, "for-each-ref", "--format=%(refname)%00%(objectname)", "refs/heads", "refs/tags")
if err != nil {
return nil, err
}
var refs []RefInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.Split(line, "\x00")
if len(parts) < 2 {
continue
}
name := parts[0]
kind := "ref"
short := name
if strings.HasPrefix(name, "refs/heads/") {
kind = "head"
short = strings.TrimPrefix(name, "refs/heads/")
} else if strings.HasPrefix(name, "refs/tags/") {
kind = "tag"
short = strings.TrimPrefix(name, "refs/tags/")
}
refs = append(refs, RefInfo{Name: name, Short: short, Kind: kind, Commit: parts[1]})
}
return refs, nil
}
func (r *Renderer) commits(ctx context.Context, repo config.Repo, ref string) ([]CommitInfo, error) {
limit := strconv.Itoa(r.MaxCommits)
format := "%H%x00%h%x00%an%x00%ae%x00%ad%x00%s"
out, err := r.Git.Output(ctx, repo.MirrorDir, "log", "--date=iso-strict-local", "-n", limit, "--pretty=format:"+format, ref, "--")
if err != nil {
return nil, err
}
var commits []CommitInfo
for _, line := range strings.Split(string(out), "\n") {
if line == "" {
continue
}
p := strings.SplitN(line, "\x00", 6)
if len(p) != 6 {
continue
}
commits = append(commits, CommitInfo{Hash: p[0], Short: p[1], Author: p[2], Email: p[3], Date: p[4], Subject: p[5]})
}
return commits, nil
}
func (r *Renderer) tree(ctx context.Context, repo config.Repo, ref string) ([]TreeEntry, error) {
out, err := r.Git.Output(ctx, repo.MirrorDir, "ls-tree", "-r", "-z", ref)
if err != nil {
return nil, err
}
var entries []TreeEntry
for _, item := range bytes.Split(out, []byte{0}) {
if len(item) == 0 {
continue
}
before, path, ok := bytes.Cut(item, []byte{'\t'})
if !ok {
continue
}
fields := strings.Fields(string(before))
if len(fields) != 3 {
continue
}
entry := TreeEntry{Mode: fields[0], Type: fields[1], Hash: fields[2], Path: string(path)}
if entry.Type == "blob" {
if sizeOut, err := r.Git.Text(ctx, repo.MirrorDir, "cat-file", "-s", entry.Hash); err == nil {
if n, err := strconv.ParseInt(strings.TrimSpace(sizeOut), 10, 64); err == nil {
entry.Size = n
}
}
}
entries = append(entries, entry)
}
return entries, nil
}
type basePage struct {
RepoManifest Manifest
GeneratedAt string
}
type blobView struct {
Path string
Size int64
RawHref string
Text string
Binary bool
}
func writeStatic(root string) error {
css := `body{max-width:1100px;margin:2rem auto;padding:0 1rem;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.45}a{color:inherit}header{border-bottom:1px solid #ddd;margin-bottom:1rem}nav a{margin-right:1rem}.muted{color:#666}.mono,pre,code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #eee;padding:.35rem;text-align:left;vertical-align:top}pre{overflow:auto;background:#f6f6f6;padding:1rem;border:1px solid #eee}.pill{border:1px solid #ddd;border-radius:999px;padding:.1rem .45rem;font-size:.85em}`
return writeFile(filepath.Join(root, "style.css"), []byte(css), 0o644)
}
func renderTemplate(path, text string, data any) error {
t, err := template.New(filepath.Base(path)).Funcs(template.FuncMap{
"short": func(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
},
"href": func(parts ...string) string {
joined := strings.Join(parts, "/")
return url.PathEscape(joined)
},
"blobHref": func(refSlug, p string) string {
return "blob/" + refSlug + "/" + strings.TrimPrefix(p, "/") + ".html"
},
}).Parse(layoutTemplate + text)
if err != nil {
return err
}
var b bytes.Buffer
if err := t.ExecuteTemplate(&b, "layout", data); err != nil {
return err
}
return writeFile(path, b.Bytes(), 0o644)
}
func writeJSON(path string, v any) error {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return err
}
return writeFile(path, b.Bytes(), 0o644)
}
func writeFile(path string, b []byte, mode fs.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, b, mode)
}
func publish(next, current, old string) error {
_ = os.RemoveAll(old)
if _, err := os.Stat(current); err == nil {
if err := os.Rename(current, old); err != nil {
return err
}
}
if err := os.Rename(next, current); err != nil {
if _, statErr := os.Stat(old); statErr == nil {
_ = os.Rename(old, current)
}
return err
}
_ = os.RemoveAll(old)
return nil
}
func firstCommits(in []CommitInfo, n int) []CommitInfo {
if len(in) <= n {
return in
}
return in[:n]
}
func firstTree(in []TreeEntry, n int) []TreeEntry {
if len(in) <= n {
return in
}
return in[:n]
}
func refSlug(ref string) string {
ref = strings.TrimPrefix(ref, "refs/heads/")
ref = strings.TrimPrefix(ref, "refs/tags/")
ref = strings.ReplaceAll(ref, "/", "__")
ref = strings.ReplaceAll(ref, "..", "_")
if ref == "" {
return "default"
}
return ref
}
func relPath(fromDir, to string) string {
rel, err := filepath.Rel(fromDir, to)
if err != nil {
return to
}
return filepath.ToSlash(rel)
}
func isSafeOutput(path string) bool {
clean := filepath.Clean(path)
return clean != "." && clean != "/" && clean != ""
}